Disclosure & OverlaysTabs

Radix tabs with a shared-layout indicator that slides between triggers.

Preview

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.
tabs.tsx
"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 };