Detent
Animated tabs with a shared-layout sliding pill indicator that flashes white as it lands and rests as a neutral pill with a subtle top hairline. 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
* flashes white as it lands and rests as a neutral pill with a subtle top
* hairline. 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>>([]);
// Bumped each time the sliding pill finishes settling into a tab; retriggers
// the white bloom pulse — the detent "landing". Stays 0 on first paint so the
// component mounts at rest, without a flash.
const [landCount, setLandCount] = React.useState(0);
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 data-crucible="detent" className={cn("flex flex-col gap-4", className)} {...props}>
<style>{`
@keyframes crucible-detent-sheen {
0% { background-position: 150% 0; }
22% { background-position: -50% 0; }
100% { background-position: -50% 0; }
}
[data-crucible="detent"] .detent-sheen {
position: absolute;
inset: 0;
border-radius: inherit;
background-image: linear-gradient(105deg, transparent 42%, rgba(255,255,255,0.16) 50%, transparent 58%);
background-size: 220% 100%;
background-repeat: no-repeat;
background-position: -50% 0;
animation: crucible-detent-sheen 6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="detent"] .detent-sheen { animation: none; }
}
`}</style>
<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 p-1",
orientation === "vertical" && "flex-col items-stretch self-stretch"
)}
style={{
// Machined groove: the track is a shaded well the pills sit down into.
backgroundImage: "linear-gradient(180deg, #171719 0%, #0f0f11 100%)",
boxShadow:
"inset 0 1px 2px rgba(0,0,0,0.65), inset 0 -1px 0 rgba(255,255,255,0.03)",
}}
>
{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/55 transition-colors duration-200",
"hover:text-white/85",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-white/55",
selected && "text-white"
)}
>
{selected && (
<motion.span
layoutId={`${baseId}-detent-indicator`}
aria-hidden
className="absolute inset-0 -z-10 rounded-full"
style={{
// Machined pill: lit from above (lighter top → darker base),
// a bright top hairline, a soft drop shadow so it rides ABOVE
// the groove, a hairline rim, and a faint neutral underglow.
backgroundImage:
"linear-gradient(180deg, #3b3b3f 0%, #2a2a2d 55%, #232326 100%)",
boxShadow: [
"inset 0 1px 0 rgba(255,255,255,0.22)",
"inset 0 -1px 0 rgba(0,0,0,0.45)",
"0 1px 1px rgba(0,0,0,0.5)",
"0 2px 6px -1px rgba(0,0,0,0.55)",
"0 0 0 1px rgba(255,255,255,0.08)",
"0 0 18px -6px rgba(255,255,255,0.2)",
].join(", "),
}}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 480, damping: 26, mass: 0.9 }
}
onLayoutAnimationComplete={() => {
if (!reducedMotion) setLandCount((c) => c + 1);
}}
>
{/* Idle life: a slow, faint sheen glints across the resting
pill every few seconds. Pure CSS; frozen under reduced motion. */}
{!reducedMotion && <span className="detent-sheen" />}
{/* Detent landing: a white brightness + bloom pulse fired the
instant the pill settles. Gated on landCount>0 so mount is calm. */}
<AnimatePresence>
{landCount > 0 && (
<motion.span
key={landCount}
className="pointer-events-none absolute inset-0 rounded-full bg-white"
style={{ boxShadow: "0 0 22px 2px rgba(255,255,255,0.45)" }}
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.42, ease: "easeOut" }}
/>
)}
</AnimatePresence>
</motion.span>
)}
<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 motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| items | DetentItem[] | Tabs to render, in order. | |
| value | string | Controlled active tab value. | |
| defaultValue | string | first enabled item | Initial active tab value when uncontrolled. |
| onValueChange | (value: string) => void | Fires whenever the active tab changes, controlled or not. | |
| orientation | "horizontal" | "vertical" | "horizontal" | Tab list orientation. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "onChange"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.