Tabs
A set of tabbed panels with a sliding pill indicator animated by Motion.
Installation
Usage
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"Examples
Default
Account settings go here.
Sizes
size steps the tabs along the control scale: sm (h-7, 12px), default (h-8, 13px), lg (h-10, 14px). The height sits on the trigger, so a tab is exactly as tall as a Nav item at the same size and the two line up in a header; the list grows by its 4px gutter. One prop on Tabs sizes the triggers, list, and count chips.
With Icons
Pass an icon to a trigger to render it before the label.
Account settings go here.
With Count
Pass a count to add a counter chip. It fills solid accent on the active tab. Add an aria-label where a bare number needs context (aria-label="Open, 18 issues").
Issues still open.
With Icons and Count
icon and count compose. Cap large counts at "99+" so a growing number can't widen the tab.
12 unread messages.
Icon Only
Omit the children for an icon-only trigger; the pill tightens to a square. Each text-less trigger needs an aria-label for an accessible name.
Grid view.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValueTabs | string | - | The tab that is active when initially rendered, for uncontrolled usage. |
| valueTabs | string | - | The controlled active tab. Pair with onValueChange. |
| onValueChangeTabs | (value: string) => void | - | Called with the new value when the active tab changes. |
| sizeTabs | "sm" | "default" | "lg" | "default" | Steps the triggers, list, and count chips along the control scale, from sm (h-7, 12px type) up to lg (h-10, 14px type). The height sits on the trigger, matching Nav item for item; the list adds its 4px gutter on top. |
| value*TabsTrigger | string | - | Identifier linking each <TabsTrigger /> to its matching <TabsContent />. |
| fullWidthTabsList | boolean | false | Stretches the list to fill its container so triggers share the width evenly. |
| iconTabsTrigger | React.ReactNode | - | Leading icon rendered before the trigger label, e.g. <Star />. |
| countTabsTrigger | React.ReactNode | - | Trailing counter chip, e.g. 18 or "99+". Fills solid accent on the active tab. |
"use client"
import * as React from "react"
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
useLayoutEffect,
} from "react"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/registry/lib/utils"
/* ─────────────────────────────────────────────────────────
* ANIMATION: SLIDING PILL
*
* ONE persistent indicator lives in TabsList and is moved to
* the measured box of the active trigger.
*
* It is deliberately NOT a per-trigger `layoutId` shared
* element. That approach mounts a new pill and unmounts the
* old one on every switch, and Motion's hand-off paints both
* boxes at once: on a long slide the pair reads as a single
* lozenge stretched across two or three tabs before it snaps
* onto the target. Dropping `AnimatePresence` reduced that
* but could not remove it, because the mount/unmount hand-off
* IS the bug. A single element that never unmounts has no
* hand-off, and its width can only interpolate between two
* real trigger widths, so it cannot balloon past either end.
*
* Spring: snappy settle, minimal bounce (0.1).
*
* `width` is animated, which is a deliberate exception to
* "animate transform and opacity only". The indicator is
* absolutely positioned with no children, so its layout is
* contained to itself and never reflows a sibling: measured
* over a full slide it drops 0 frames in 73. The alternative,
* scaleX, puts a non-unit scale on a 6px radius and visibly
* distorts the corners. Position still rides a transform;
* only width touches layout.
* ───────────────────────────────────────────────────────── */
// Paired with the trigger's own `transition-[color] duration-150`: the pill and
// the label are ONE selection, so they share a duration and land together.
// A 200ms spring under a 150ms color change made the destination read as
// selected while the pill was still in transit. `bounce: 0` because tabs are
// chrome you click all day, and one bouncy control in a crisp kit reads as a
// mistake. Change one of these two values and you must change the other.
const PILL = {
spring: { type: "spring" as const, visualDuration: 0.15, bounce: 0 },
}
// useLayoutEffect warns during SSR; fall back to useEffect there.
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect
type TabsSize = "sm" | "default" | "lg"
// Sizes ride the kit's control scale (Button/Select tiers), and the TRIGGER is
// what carries them: heights h-7 / h-8 / h-10, type 12px / 13px / 14px, icons
// one step behind, padding and icon gap stepping with it. Every value here is
// the one a horizontal NavItem uses at the same size, so a tab and a nav item
// are the same object at the same scale.
//
// The height belongs on the trigger, not the list. The trigger is the pill you
// click, the same role NavItem plays; the list is only the track around it. Put
// the control height on the list instead and the pill silently renders SHORTER
// than its size name by however much padding the list happens to carry. The
// list now sizes itself from the trigger plus its `p-1` (36 / 40 / 48px),
// which is also the "children own their spacing" rule.
const TRIGGER_SIZE: Record<
TabsSize,
{ shared: string; text: string; iconOnly: string; label: string }
> = {
sm: {
shared: "h-7 pointer-coarse:min-h-8 text-1xs [&_svg:not([class*='size-'])]:size-3",
text: "px-2",
iconOnly: "px-1.5",
label: "gap-1.5",
},
default: {
shared: "h-8 text-xs [&_svg:not([class*='size-'])]:size-3.5",
text: "px-2.5",
iconOnly: "px-1.5",
label: "gap-2",
},
lg: {
shared: "h-10 text-sm [&_svg:not([class*='size-'])]:size-4",
text: "px-3",
iconOnly: "px-2",
label: "gap-2",
},
}
const COUNT_SIZE: Record<TabsSize, string> = {
sm: "h-4 min-w-4 px-1 text-3xs [&_svg]:!size-2.5",
default: "h-4 min-w-4 px-1 text-2xs [&_svg]:!size-2.5",
lg: "h-5 min-w-5 px-1.5 text-1xs [&_svg]:!size-3",
}
const TabsContext = createContext<{
activeValue: string | undefined
size: TabsSize
}>({ activeValue: undefined, size: "default" })
function Tabs({
className,
defaultValue,
value,
onValueChange,
size = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root> & {
size?: TabsSize
}) {
const [internalValue, setInternalValue] = useState(defaultValue)
const activeValue = value ?? internalValue
const handleValueChange = useCallback(
(...args: Parameters<NonNullable<typeof onValueChange>>) => {
setInternalValue(args[0])
onValueChange?.(...args)
},
[onValueChange]
)
return (
<TabsContext.Provider value={{ activeValue, size }}>
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
value={activeValue}
onValueChange={handleValueChange}
{...props}
/>
</TabsContext.Provider>
)
}
function TabsList({
className,
fullWidth = false,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & {
fullWidth?: boolean
}) {
// Size is not read here: the list has no size-dependent class of its own, and
// a size change reaches the indicator through the ResizeObserver on the
// triggers, which is what actually resized.
const { activeValue } = useContext(TabsContext)
const listRef = useRef<HTMLDivElement>(null)
const [pill, setPill] = useState<{
x: number
y: number
width: number
height: number
} | null>(null)
const shouldReduceMotion = useReducedMotion()
useIsomorphicLayoutEffect(() => {
const list = listRef.current
if (!list) return
const measure = () => {
// Match on the trigger's own value rather than a presence attribute, so
// measuring never races the primitive setting `data-active`.
const active = Array.from(
list.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]')
).find((el) => el.dataset.value === String(activeValue))
// Measure the trigger's full box, including offsetTop/offsetHeight. An
// absolute child is positioned against the list's PADDING box, while the
// trigger sits inside the list's `p-1`. Deriving the vertical box from
// the trigger keeps the indicator inset correctly whatever that padding is.
setPill(
active
? {
x: active.offsetLeft,
y: active.offsetTop,
width: active.offsetWidth,
height: active.offsetHeight,
}
: null
)
}
measure()
// Triggers resize on font load, container resize, and count changes.
const observer = new ResizeObserver(measure)
observer.observe(list)
list
.querySelectorAll('[data-slot="tabs-trigger"]')
.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [activeValue, children])
return (
<TabsPrimitive.List
ref={listRef}
data-slot="tabs-list"
className={cn(
// `relative` anchors the indicator: a trigger's offsetLeft and an
// absolute child's left:0 both resolve to this element's padding box.
// No external margin: spacing below the tabs is the caller's job. For
// the tabs-above-content pattern, add your own gap (e.g. `mb-3`).
// Nested radii are concentric: outer = inner + gutter, so the 12px list
// needs a 4px `p-1` around its 8px pill. A 2px gutter would call for a
// 10px inner radius, which is not a step on the ladder.
// No height here: the list takes its size from the triggers plus this
// padding, so a tab is exactly as tall as the NavItem of the same size.
"text-muted-foreground relative inline-flex items-center justify-center rounded-lg border border-neutral-500/30 p-1 bg-neutral-50 dark:bg-neutral-900 max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
fullWidth ? "w-full" : "w-fit",
className
)}
{...props}
>
{/* Rendered before the triggers so they paint over it. */}
{pill && (
<motion.span
aria-hidden
data-slot="tabs-indicator"
className="pointer-events-none absolute left-0 rounded-md bg-accent-100 dark:bg-accent-900"
// Vertical box is set, not animated: every trigger in a list shares
// one height from its size tier, so top/height are identical for all
// of them and only x and width ever change.
style={{ top: pill.y, height: pill.height }}
// initial={false}: adopt the first measurement instead of animating
// in from x:0/width:0 on mount.
initial={false}
animate={{ x: pill.x, width: pill.width }}
transition={shouldReduceMotion ? { duration: 0 } : PILL.spring}
/>
)}
{children}
</TabsPrimitive.List>
)
}
function TabsTrigger({
className,
children,
value,
icon,
count,
...props
}: React.ComponentProps<typeof TabsPrimitive.Tab> & {
icon?: React.ReactNode
count?: React.ReactNode
}) {
const { activeValue, size } = useContext(TabsContext)
const isActive = value === activeValue
// Icon-only: an icon with no label. Uses symmetric padding so the lone icon
// sits in a square-ish pill instead of the text-tuned `px-3`. Callers MUST
// pass an `aria-label` for a text-less trigger (there is no visible name).
// A counter is a visible datum, so a trigger carrying one is never icon-only.
const isIconOnly = icon != null && children == null && count == null
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
// Read by TabsList to measure the indicator's target box.
data-value={String(value)}
value={value}
className={cn(
// svg size is scoped with :not so a nested icon (e.g. inside a count
// chip) can set its own size-* instead of losing to this rule.
// No `motion-reduce:transition-none` on the color fade: a color change
// is non-vestibular and aids comprehension, so reduced motion keeps it.
// Only the indicator's travel is suppressed.
// `transition-[color]`, NOT `transition-colors`: background-color must
// NOT transition here. The indicator renders before the triggers so
// they paint over it, which means a hover fill that fades out sits ON
// TOP of the arriving pill and muddies it. Clearing the fill instantly
// also matches the frequency principle: a tab is a 100+/day hover, and
// those should not animate.
"relative data-[active]:text-accent text-muted-foreground font-medium transition-[color] duration-150 focus-visible:ring-ring-focus focus-visible:outline-ring inline-flex flex-1 items-center justify-center gap-2 rounded-md normal-case tracking-normal whitespace-nowrap focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:shrink-0",
TRIGGER_SIZE[size].shared,
isIconOnly ? TRIGGER_SIZE[size].iconOnly : TRIGGER_SIZE[size].text,
className
)}
{...props}
>
<span
className={cn(
"relative z-10 inline-flex items-center",
TRIGGER_SIZE[size].label
)}
>
{icon}
{children}
</span>
{count != null && (
<span
data-slot="tabs-trigger-count"
className={cn(
// Active: solid accent FILL, not an accent number (accent-on-accent
// fails contrast). tabular-nums so a live count doesn't jitter.
// `transition-colors` (background included) is correct HERE and
// nowhere else in this file: `z-10` puts the chip above the pill,
// not under it, so its fill can cross-fade without muddying the
// indicator. 150ms keeps it paired with the label and the pill.
"relative z-10 inline-flex items-center justify-center gap-1 rounded-full font-medium tabular-nums transition-colors duration-150 [&_svg]:shrink-0",
COUNT_SIZE[size],
isActive
? "bg-accent text-accent-foreground"
: "bg-neutral-500/10 dark:bg-neutral-500/15 text-muted-foreground"
)}
>
{count}
</span>
)}
</TabsPrimitive.Tab>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Panel>) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn(
"flex-1 outline-none",
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }