Tabs
pnpm dlx shadcn@latest add …/r/tabs.jsonAccessible React tabs for Next.js, with an underline or pill that slides to the active tab and panels that glide in, built on Radix Tabs.
- Underline
Shown on your profile and in emails.
- Pill
Shown on your profile and in emails.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export function Settings() {
return (
<Tabs defaultValue="account">
<TabsList aria-label="Settings">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Change your name and email.</TabsContent>
<TabsContent value="password">Change your password.</TabsContent>
</Tabs>
)
}import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useId } from "react";
const variants = [
{ variant: "underline", label: "Underline" },
{ variant: "pill", label: "Pill" },
] as const;
/** One settings set: each panel is a different height, so switching shows it gliding to its size */
function Settings({ id, variant, label }: { id: string; variant: "underline" | "pill"; label: string }) {
return (
<Tabs defaultValue="account">
<TabsList variant={variant} aria-label={`Settings, ${label.toLowerCase()} style`}>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
</TabsList>
<TabsContent value="account" className="grid gap-2">
<label htmlFor={`${id}-name`} className="text-sm font-medium">
Name
</label>
<Input id={`${id}-name`} defaultValue="Ada Lovelace" aria-describedby={`${id}-name-hint`} />
<p id={`${id}-name-hint`} className="text-sm text-muted-foreground">
Shown on your profile and in emails.
</p>
</TabsContent>
<TabsContent value="password" className="grid gap-4">
<div className="grid gap-2">
<label htmlFor={`${id}-current`} className="text-sm font-medium">
Current password
</label>
<Input id={`${id}-current`} type="password" autoComplete="current-password" />
</div>
<div className="grid gap-2">
<label htmlFor={`${id}-new`} className="text-sm font-medium">
New password
</label>
<Input id={`${id}-new`} type="password" autoComplete="new-password" />
</div>
</TabsContent>
<TabsContent value="notifications" className="grid gap-4">
<div className="flex items-center justify-between gap-4">
<label htmlFor={`${id}-email`} className="text-sm font-medium">
Email updates
</label>
<Switch id={`${id}-email`} defaultChecked />
</div>
<div className="flex items-center justify-between gap-4">
<label htmlFor={`${id}-push`} className="text-sm font-medium">
Push notifications
</label>
<Switch id={`${id}-push`} />
</div>
</TabsContent>
</Tabs>
);
}
/*
* Both looks side by side, named underneath. Top-aligned, so switching one set never moves the other.
*/
export default function TabsVariants() {
const id = useId();
return (
<ul className="flex w-full flex-wrap items-start justify-center gap-x-10 gap-y-8">
{variants.map(({ variant, label }) => (
<li key={variant} className="flex w-72 flex-col gap-4">
<Settings id={`${id}-${variant}`} variant={variant} label={label} />
<span className="text-center text-xs text-muted-foreground">{label}</span>
</li>
))}
</ul>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { blur, duration, ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { type AnimationPlaybackControls, animate, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { Tabs as TabsPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type Ref, type RefObject, useContext, useId, useLayoutEffect, useRef, useState, } from "react"; /* * Tabs, built on Radix Tabs (roadmap, 2026-10-16: "Panels switched by an underline that slides between tabs"). * - The indicator slides to the active tab (layoutId on spring.snappy, .agents/rules/motion.md), scoped to each * list, so two tab sets never trade it. Two looks: `underline` (the default), a 2px line riding the list's * hairline, and `pill`, Switcher's filled pill on a muted track. * - The panel that appears comes through the blur and slides 12px in from the side of the tab you moved to * (from the right when you move right), while its height glides from the old panel's to its own: * duration.slow on ease.out, never a spring, clipped only while it moves. The filter and height are cleared * afterwards. The old panel goes at once, so the two never overlap. * - Nothing animates on first paint. With reduced motion the indicator jumps and the panel only fades. * - A tablist of tabs, each controlling a tabpanel. Arrow keys move between tabs and show their panel; Home * and End jump to the ends. */ type Variant = "underline" | "pill"; /** How far the new panel slides in, in px */ const SLIDE = 12; /** The inline styles an entrance leaves on a panel, cleared when it's hidden again */ const LEFTOVERS = ["opacity", "transform", "filter", "height", "overflow"]; type TabsContextValue = { value: string; reduce: boolean; root: RefObject<HTMLDivElement | null>; /** The tab shown before the latest change. The root updates it after the panels' effects have read it. */ previous: RefObject<string>; /** The shown panel's height, kept current, so the next panel glides from it */ height: RefObject<number>; }; const TabsContext = createContext<TabsContextValue | null>(null); const ListContext = createContext<{ variant: Variant }>({ variant: "underline" }); /** Hands a node to our ref and to the caller's */ function useMergedRef<T>(own: { current: T | null }, theirs?: Ref<T>) { return (node: T | null) => { own.current = node; if (typeof theirs === "function") theirs(node); else if (theirs) (theirs as { current: T | null }).current = node; }; } /** A tab of this set (not of a set nested inside one of its panels) */ function triggerOf(root: HTMLElement, value: string) { return Array.from(root.querySelectorAll<HTMLElement>("[data-slot=tabs-trigger]")).find( (trigger) => trigger.dataset.value === value && trigger.closest("[data-slot=tabs]") === root, ); } /** * Panels of related content, one shown at a time, built on Radix Tabs. Holds the value (controlled or not) * so the indicator and the panels know which tab is shown. */ function Tabs({ value, defaultValue, onValueChange, className, ref, ...props }: ComponentProps<typeof TabsPrimitive.Root>) { const reduce = useReducedMotion() ?? false; const [internal, setInternal] = useState(defaultValue ?? ""); const current = value ?? internal; const root = useRef<HTMLDivElement>(null); const setRef = useMergedRef(root, ref); const previous = useRef(current); const height = useRef(0); // Parents' layout effects run after their children's, so the panel that just appeared has already read the // tab it replaced useLayoutEffect(() => { previous.current = current; }, [current]); return ( <TabsContext.Provider value={{ value: current, reduce, root, previous, height }}> <TabsPrimitive.Root ref={setRef} data-slot="tabs" value={current} onValueChange={(next) => { setInternal(next); onValueChange?.(next); }} className={cn("flex flex-col gap-4", className)} {...props} /> </TabsContext.Provider> ); } const tabsListVariants = cva("relative isolate flex items-center", { variants: { variant: { // The hairline is the track the underline rides on, and divides the tabs from the panel underline: "gap-4 border-b border-border", // The tabs sit 2px inside the track, so the track is a control height (36px) pill: "w-fit gap-0.5 rounded-lg bg-muted p-0.5", }, }, defaultVariants: { variant: "underline" }, }); const tabsTriggerVariants = cva( [ "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap", // The label's colour changes over the indicator's time, so the two arrive together "text-muted-foreground transition-colors duration-(--duration-base) ease-out hover:text-foreground data-[state=active]:text-foreground", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "disabled:pointer-events-none disabled:opacity-(--opacity-disabled)", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)", ], { variants: { variant: { underline: "h-(--size-control-md) px-1", pill: "h-8 px-3", }, }, defaultVariants: { variant: "underline" }, }, ); export type TabsListProps = ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>; /** The row of tabs. Give it a name with aria-label when the page has more than one set. */ function TabsList({ className, variant, children, ...props }: TabsListProps) { const groupId = useId(); return ( <ListContext.Provider value={{ variant: variant ?? "underline" }}> <TabsPrimitive.List data-slot="tabs-list" data-variant={variant ?? "underline"} className={cn(tabsListVariants({ variant }), className)} {...props} > <LayoutGroup id={groupId}>{children}</LayoutGroup> </TabsPrimitive.List> </ListContext.Provider> ); } function TabsTrigger({ className, value, children, ref, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) { const tabs = useContext(TabsContext); const { variant } = useContext(ListContext); const node = useRef<HTMLButtonElement>(null); const setRef = useMergedRef(node, ref); const [radius, setRadius] = useState<number>(); // A numeric radius lets motion keep the pill's corners true while it changes width between tabs. Until it's // read (server HTML), the class gives the same corners. useLayoutEffect(() => { if (variant === "pill" && node.current) setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || undefined); }, [variant]); return ( <TabsPrimitive.Trigger ref={setRef} data-slot="tabs-trigger" data-value={value} value={value} className={cn(tabsTriggerVariants({ variant }), className)} {...props} > {tabs?.value === value ? ( // Tabs aren't stacking contexts: the indicator joins the list's layer (it's `isolate`), so the pill // passes behind every label as it slides <motion.span layoutId="tabs-indicator" aria-hidden data-slot="tabs-indicator" className={ variant === "pill" ? "pointer-events-none absolute inset-0 -z-10 rounded-md bg-background" : // On the list's hairline: its lower pixel covers it "pointer-events-none absolute inset-x-0 -bottom-px h-0.5 bg-foreground" } style={radius ? { borderRadius: radius } : undefined} transition={tabs.reduce ? { duration: 0 } : spring.snappy} /> ) : null} {children} </TabsPrimitive.Trigger> ); } export type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>; /** * One tab's panel. Hidden panels render nothing, unless `forceMount` keeps their content (and its state) * mounted while hidden. */ function TabsContent({ className, value, forceMount, children, ref, ...props }: TabsContentProps) { const tabs = useContext(TabsContext); const node = useRef<HTMLDivElement>(null); const setRef = useMergedRef(node, ref); const active = tabs?.value === value; // The panel is shown or hidden in the same commit as the tab changes (Radix's Presence would show it one // render later), so its entrance can measure it and start before the first paint, never flashing in place // biome-ignore lint/correctness/useExhaustiveDependencies: only the panel being shown or hidden starts or stops its entrance useLayoutEffect(() => { const panel = node.current; if (!tabs || !active || !panel) return; const { root, previous, height, reduce } = tabs; // The shown panel's height, mid-glide too, so the next panel glides from wherever this one is const observer = new ResizeObserver(() => { height.current = panel.offsetHeight; }); observer.observe(panel); const running: AnimationPlaybackControls[] = []; const from = previous.current; // On first paint the panel is simply there if (from !== value) { const box = root.current; const before = box ? triggerOf(box, from) : undefined; const after = box ? triggerOf(box, value) : undefined; // From the right when the new tab is to the right of the old one. Offsets, so right-to-left pages and // zoomed previews work too. const side = before && after && after.offsetLeft < before.offsetLeft ? -1 : 1; const start = height.current; const end = panel.offsetHeight; if (reduce) { running.push(animate(panel, { opacity: [0, 1] }, { duration: duration.base, ease: ease.out })); } else { const entrance = animate( panel, { opacity: [0, 1], x: [side * SLIDE, 0], filter: [blur.hidden, blur.shown] }, { duration: duration.base, ease: ease.out }, ); // A filter left behind would make a stacking context and a containing block round the content entrance.then(() => panel.style.removeProperty("filter")); running.push(entrance); if (start > 0 && end > 0 && start !== end) { panel.style.overflow = "hidden"; const glide = animate(panel, { height: [start, end] }, { duration: duration.slow, ease: ease.out }); glide.then(() => { panel.style.removeProperty("height"); panel.style.removeProperty("overflow"); }); running.push(glide); } } } // Hidden again, mid-entrance or not: stop, and leave nothing behind for the next time it's shown return () => { observer.disconnect(); for (const animation of running) animation.stop(); for (const property of LEFTOVERS) panel.style.removeProperty(property); }; }, [active]); return ( <TabsPrimitive.Content ref={setRef} data-slot="tabs-content" value={value} // We decide what's shown and mounted, so the panel changes in the same render as its tab forceMount hidden={!active} // No flex-1 (unlike shadcn): a flex-basis would override the height the glide sets. Add it to fill a // fixed-height Tabs. className={cn( "rounded-md outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", className, )} {...props} > {active || forceMount ? children : null} </TabsPrimitive.Content> ); } export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants, tabsTriggerVariants };Update the import paths to match your project.
Examples
Icons
Icons sit before the label at the icon size. Give icon-only tabs an aria-label.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ActivityIcon, LayoutDashboardIcon, SettingsIcon } from "lucide-react";
export default function TabsIcons() {
return (
<Tabs defaultValue="overview" className="w-full max-w-sm">
<TabsList variant="pill" aria-label="Project">
<TabsTrigger value="overview">
<LayoutDashboardIcon aria-hidden />
Overview
</TabsTrigger>
<TabsTrigger value="activity">
<ActivityIcon aria-hidden />
Activity
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon aria-hidden />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="text-sm text-muted-foreground">
Three deployments this week, all healthy.
</TabsContent>
<TabsContent value="activity" className="text-sm text-muted-foreground">
Ada merged two pull requests and opened an issue about the sign-in page.
</TabsContent>
<TabsContent value="settings" className="text-sm text-muted-foreground">
Name, domain and who can see this project.
</TabsContent>
</Tabs>
);
}Disabled
A disabled tab can't be picked, and the arrow keys skip it.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function TabsDisabled() {
return (
<Tabs defaultValue="account" className="w-full max-w-xs">
<TabsList aria-label="Settings">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="billing" disabled>
Billing
</TabsTrigger>
<TabsTrigger value="team">Team</TabsTrigger>
</TabsList>
<TabsContent value="account" className="text-sm text-muted-foreground">
Your name and email, as others see them.
</TabsContent>
<TabsContent value="billing" className="text-sm text-muted-foreground">
Your plan, payment method and invoices.
</TabsContent>
<TabsContent value="team" className="text-sm text-muted-foreground">
Invite people and choose what they can change.
</TabsContent>
</Tabs>
);
}API reference
Tabs
The root, built on Radix Tabs. Also accepts every Radix Tabs Root prop. Arrow keys move between tabs and show their panel; Home and End jump to the ends.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | string | – | The tab shown, when you control it. |
defaultValue? | string | – | The tab shown at first. |
onValueChange? | (value: string) => void | – | Called with the new tab when the user picks one. |
activationMode? | "automatic" | "manual" | "automatic" | Automatic shows a tab's panel as soon as the arrow keys reach it; manual waits for Enter or Space. |
dir? | "ltr" | "rtl" | – | The reading direction. The arrow keys follow it, and a panel still slides in from the side of its tab. |
TabsList
The row of tabs, a tablist. Give it a name with aria-label when the page has more than one set.
| Prop | Type | Default | Description |
|---|---|---|---|
variant? | "underline" | "pill" | "underline" | Underline: a line slides along a hairline under the tabs. Pill: a filled pill slides on a muted track, like Switcher. |
loop? | boolean | true | The arrow keys wrap from the last tab to the first. |
TabsTrigger
One tab.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The panel this tab shows. |
disabled? | boolean | false | Skips the tab. |
TabsContent
One tab's panel, a tabpanel named by its tab. It comes in through a blur and slides 12px from the side you moved towards, while its height glides from the last panel's.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The tab that shows this panel. |
forceMount? | true | – | Keeps the panel's content mounted while it's hidden, so it keeps its state (a half-filled form) and stays in the HTML. |
Related components
An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.
- Yes. It follows the WAI-ARIA accordion pattern.Yes, server components included.
An accessible React accordion for Next.js: sections that open and close with a smooth height change, as one card, separate cards or plain rows. Built on Radix.
An accessible React toggle group for Next.js: buttons you switch on and off independently, like Bold, Italic and Underline in a toolbar.