Detent
Animated tabs with a shared-layout sliding pill indicator that glows amber in transit and settles with a violet hairline top edge. Full roving-tabindex keyboard support with arrow keys, Home/End, and correct tablist/tab/tabpanel roles.
"use client";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface DetentItem {
/** Unique, URL-safe-ish value identifying this tab. */
value: string;
/** Tab label. */
label: React.ReactNode;
/** Panel content shown when this tab is active. */
content: React.ReactNode;
/** Disable this tab — it's skipped by pointer and keyboard navigation. */
disabled?: boolean;
}
export interface DetentProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange"> {
/** Tabs to render, in order. */
items: DetentItem[];
/** Controlled active tab value. */
value?: string;
/** Initial active tab value when uncontrolled. @default first enabled item */
defaultValue?: string;
/** Fires whenever the active tab changes, controlled or not. */
onValueChange?: (value: string) => void;
/** Tab list orientation. @default "horizontal" */
orientation?: "horizontal" | "vertical";
}
/**
* Detent — animated tabs with a shared-layout sliding pill indicator that
* glows amber in transit and settles with a violet hairline top edge. Full
* roving-tabindex keyboard support: Arrow keys move and activate, Home/End
* jump to the ends, disabled tabs are skipped. Indicator motion collapses to
* an instant snap under `prefers-reduced-motion`.
*/
export function Detent({
items,
value,
defaultValue,
onValueChange,
orientation = "horizontal",
className,
...props
}: DetentProps) {
const reducedMotion = useReducedMotion();
const baseId = React.useId();
const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const firstEnabled = items.find((item) => !item.disabled)?.value ?? items[0]?.value;
const [internalValue, setInternalValue] = React.useState(defaultValue ?? firstEnabled);
const activeValue = value ?? internalValue;
const selectTab = React.useCallback(
(next: string) => {
if (value === undefined) setInternalValue(next);
onValueChange?.(next);
},
[value, onValueChange]
);
const nextEnabledIndex = React.useCallback(
(from: number, direction: 1 | -1) => {
const count = items.length;
let index = from;
for (let step = 0; step < count; step++) {
index = (index + direction + count) % count;
if (!items[index]?.disabled) return index;
}
return from;
},
[items]
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = items.findIndex((item) => item.value === activeValue);
const forwardKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const backwardKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
let nextIndex: number | null = null;
if (event.key === forwardKey) nextIndex = nextEnabledIndex(currentIndex, 1);
else if (event.key === backwardKey) nextIndex = nextEnabledIndex(currentIndex, -1);
else if (event.key === "Home") nextIndex = nextEnabledIndex(-1, 1);
else if (event.key === "End") nextIndex = nextEnabledIndex(0, -1);
if (nextIndex === null) return;
event.preventDefault();
const nextItem = items[nextIndex];
if (!nextItem) return;
selectTab(nextItem.value);
tabRefs.current[nextIndex]?.focus();
},
[items, activeValue, orientation, nextEnabledIndex, selectTab]
);
return (
<div className={cn("flex flex-col gap-4", className)} {...props}>
<div
role="tablist"
aria-orientation={orientation}
onKeyDown={handleKeyDown}
className={cn(
"relative inline-flex items-center gap-1 self-start rounded-full border border-white/10 bg-neutral-900 p-1",
orientation === "vertical" && "flex-col items-stretch self-stretch"
)}
>
{items.map((item, index) => {
const selected = item.value === activeValue;
return (
<button
key={item.value}
ref={(el) => {
tabRefs.current[index] = el;
}}
role="tab"
type="button"
id={`${baseId}-tab-${item.value}`}
aria-selected={selected}
aria-controls={`${baseId}-panel-${item.value}`}
tabIndex={selected ? 0 : -1}
disabled={item.disabled}
onClick={() => !item.disabled && selectTab(item.value)}
className={cn(
"relative z-10 rounded-full px-4 py-1.5 text-sm font-medium whitespace-nowrap text-white/60 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff6b35] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900",
"disabled:cursor-not-allowed disabled:opacity-40",
selected && "text-white"
)}
>
{selected && (
<motion.span
layoutId={`${baseId}-detent-indicator`}
className="absolute inset-0 -z-10 rounded-full bg-neutral-800"
style={{
boxShadow:
"inset 0 1px 0 rgba(90,24,154,0.55), 0 0 14px rgba(255,107,53,0.35), 0 0 0 1px rgba(255,255,255,0.06)",
}}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 620, damping: 30 }
}
/>
)}
<AnimatePresence>
{selected && !reducedMotion && (
<motion.span
key={`${item.value}-flash`}
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 rounded-full bg-[#ff6b35]"
initial={{ opacity: 0.45 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
)}
</AnimatePresence>
<span className="relative">{item.label}</span>
</button>
);
})}
</div>
{items.map((item) => (
<div
key={item.value}
role="tabpanel"
id={`${baseId}-panel-${item.value}`}
aria-labelledby={`${baseId}-tab-${item.value}`}
hidden={item.value !== activeValue}
tabIndex={0}
className="focus-visible:outline-none"
>
{item.content}
</div>
))}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/detentManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.