Disclosure & OverlaysTabs
Radix tabs with a shared-layout indicator that slides between triggers.
Twelve components shipped this wave, all on Radix primitives with the house motion vocabulary.
The thinking
Tabs exist so a person can switch what they're looking at without losing their place, and the one decision the whole component is built around is that the selected pill should be a single object that moves, not a style that blinks from one trigger to the next. That forced a small structural problem: a shared-layout element has to know which trigger is active at render time, and Radix keeps the active value private. So the root mirrors it into
TabsValueContext — Radix stays the source of truth and hands me the next value at commit, I just read it back in each trigger to decide who owns the pill. LayoutGroup namespaces the layoutId with a useId, so two sets of tabs on one page never reach across and trade indicators. The detail I care about is that the pill is the only thing that animates; the label color is a flat transition-colors, because moving the indicator and recoloring the text are two different thoughts and only one of them is traveling.Interface design
The list is a recessed tray —
rounded-lg, bg-secondary-dim, h-9 with p-1 of breathing room — and every label inside it rests in text-muted-foreground. The active tab is the one lifted out of the well: the pill under it is bg-card with a border-border-faint edge, the same raised-surface language the rest of the site uses for cards, and the label follows it up with data-[state=active]:text-foreground. Two text tokens, muted and full, nothing in between — the elevation does the talking, so the type doesn't have to. The radii nest the same way, rounded-lg on the tray and rounded-md on the pill inside the padding, so the inner corners follow the outer ones instead of fighting them. The panel below stays plain — mt-3 text-sm and a focus ring — because the content is the destination, not part of the control.Interaction
The active pill is one
motion.span with a layoutId, rendered only inside the active trigger — when selection moves, motion measures both positions and the pill travels between them instead of teleporting, carrying the relationship between old and new selection. It gets a spring, not a curve, because it's an object with a destination and mass reads as honesty here; SPRING.gentle — mass 0.2, stiffness 170, damping 24 — settles without overshoot. The keyboard gets the same reading of intent: Radix's activation is automatic, so arrowing onto a trigger selects it with no second keypress — moving focus along a tablist already says what you want to see, and demanding Enter on top of that would thicken the layer between wanting and seeing. useReducedMotion swaps the travel for { duration: 0 } — same information, it just arrives.Sound design
One short note per selection, and none of it lives in this file: the trigger carries
role="tab", so the SoundLayer's capturing listener taps it like any other control. Selection among siblings doesn't earn a pair — open/close pairs are for things that change where you are, and a tab only changes what you're looking at.Empathy
The semantics are Radix's — tablist, tab, and tabpanel roles with the wiring between them, inherited rather than reimplemented — and my one addition is subtractive: the pill is
aria-hidden, so a screen reader hears which tab is selected and never the rectangle decorating it. Keyboard focus draws focus-visible:ring-1 on the ring token, and TabsContent carries the same ring, because Radix makes the panel itself a tab stop — land on it and something should say so. A disabled trigger is disabled:opacity-50 plus disabled:pointer-events-none, and since it's still a real button underneath, the SoundLayer's :disabled check keeps it quiet too. Reduced motion is respected once per animation: useReducedMotion collapses the pill's travel to { duration: 0 }, and motion-reduce:transition-none drops the label's color fade. Long labels get whitespace-nowrap — a trigger widens rather than wraps — and past that I do nothing; a tab whose label needs truncating needs a shorter name, not an ellipsis."use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { SPRING } from "@/craft/lib/motion";
// the indicator is a shared-layout element, so each trigger needs to
// know whether it's the active one at render time. radix keeps that
// private, so the root mirrors the value into context — radix stays
// the source of truth and hands us the next value at commit time.
const TabsValueContext = React.createContext<string | undefined>(undefined);
export interface TabsProps
extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> {
ref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.Root>>;
}
const Tabs = ({
ref,
value,
defaultValue,
onValueChange,
...props
}: TabsProps) => {
const [internalValue, setInternalValue] = React.useState(defaultValue);
const currentValue = value ?? internalValue;
return (
<TabsValueContext.Provider value={currentValue}>
<TabsPrimitive.Root
ref={ref}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => {
setInternalValue(next);
onValueChange?.(next);
}}
{...props}
/>
</TabsValueContext.Provider>
);
};
Tabs.displayName = TabsPrimitive.Root.displayName;
const TabsList = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
ref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.List>>;
}) => {
// LayoutGroup namespaces the indicator's layoutId, so two Tabs on
// one page never trade indicators
const groupId = React.useId();
return (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-secondary-dim p-1 text-muted-foreground",
className
)}
{...props}
>
<LayoutGroup id={groupId}>{children}</LayoutGroup>
</TabsPrimitive.List>
);
};
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = ({
ref,
className,
value,
children,
...props
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
ref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.Trigger>>;
}) => {
const isActive = React.useContext(TabsValueContext) === value;
const reduceMotion = useReducedMotion();
return (
<TabsPrimitive.Trigger
ref={ref}
value={value}
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-colors duration-150 ease-swift focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground motion-reduce:transition-none",
className
)}
{...props}
>
{isActive && (
// rendered only on the active trigger — the shared layoutId is
// what makes it travel instead of remount
<motion.span
layoutId="tabs-indicator"
transition={reduceMotion ? { duration: 0 } : SPRING.gentle}
className="absolute inset-0 rounded-md border border-border-faint bg-card"
aria-hidden
/>
)}
<span className="relative">{children}</span>
</TabsPrimitive.Trigger>
);
};
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {
ref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.Content>>;
}) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
{...props}
/>
);
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };