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
Horizontal
Default
Each TabsTrigger is tied to its panel by a matching value string. Mistype either one and the tab still selects, the panel just comes up blank, with nothing to tell you why.
Sizes
size steps the tabs along the control scale: sm (h-7, 12px), default (h-8, 13px), lg (h-10, 14px). One prop on Tabs sizes the triggers, the list, and the count chips. The height sits on the trigger, so a tab lines up with a Nav item of the same size; the list adds its 4px gutter on top.
With Icons
Pass an icon to a trigger to render it before the label.
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").
With Icons and Count
icon and count compose. Cap large counts at "99+" so a growing number can't widen the tab.
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.
Vertical
Default
orientation="vertical" stacks the triggers into a column beside the panels instead of above them, the settings-pane pattern. The gap moves to the inline axis with it, so the list takes mr-3 where a horizontal one takes mb-3. Labels align to the leading edge, and the sliding indicator travels vertically.
Sizes
The same three rungs as a horizontal strip, and the same heights: sm (h-7, 12px), default (h-8, 13px), lg (h-10, 14px). One prop on Tabs sizes the triggers, the labels, and the count chips in either orientation, so a vertical strip lines up with a Nav sidebar of the same size.
With Count
A count sits at the trailing edge of a vertical trigger, not against the label, so a column of numbers reads as a column. That needs a width on the TabsList: the strip sizes to its own content, and there is no shared trailing edge to push a count to until you give it one.
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. |
| orientationTabs | "horizontal" | "vertical" | "horizontal" | Axis of the tab strip. vertical stacks the triggers into a column and moves the strip beside the panels instead of above them; the sliding indicator travels vertically to match. |
| 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. Horizontal lists share the width between triggers; a vertical list sizes each row to its content. |
| 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.
//
// Only `sm` carries a pointer-coarse bump. It is the one rung under the 32px
// floor (h-7), so it grows to 32; `default` (32) and `lg` (40) already clear it,
// and bumping them would only reflow a dense strip for nothing. Same reasoning
// as button-variants.ts's short rungs; NavItem's `sm` does the same thing.
//
// Text padding steps 8 / 12 / 12 rather than 8 / 10 / 12: 10px is off the 4px
// grid, and `default` is a 32px control carrying 13px type, the same object a
// 32px Button is, so it takes the same 12px inset. `lg` shares that inset but is
// 8px taller, so the two never read alike.
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-3",
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-2xs [&_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
orientation: "horizontal" | "vertical"
}>({ activeValue: undefined, size: "default", orientation: "horizontal" })
// @use-when swapping the content panel below a row of labels.
function Tabs({
className,
defaultValue,
value,
onValueChange,
size = "default",
orientation = "horizontal",
...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, orientation }}>
<TabsPrimitive.Root
data-slot="tabs"
// Vertical puts the strip BESIDE the panels rather than above them, so
// the root's own axis flips with it. The gap is the same either way.
className={cn(
"flex gap-2",
orientation === "vertical" ? "flex-row" : "flex-col",
className
)}
orientation={orientation}
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, orientation } = useContext(TabsContext)
const isVertical = orientation === "vertical"
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 rounded-lg border border-neutral-500/30 p-1 bg-neutral-50 dark:bg-neutral-900 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
// The strip scrolls along its OWN axis. A vertical list that kept
// `overflow-x-auto` would clip a long column instead of scrolling it.
isVertical
? "flex-col items-stretch max-h-full overflow-y-auto"
: "items-center justify-center max-w-full overflow-x-auto",
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 top-0 left-0 rounded-md bg-accent-100 dark:bg-accent-800"
// Height is set, not animated: every trigger in a list shares one
// height from its size tier, so it is identical for all of them in
// either orientation.
// BOTH axes animate. `y` used to be an inline `top`, which is correct
// for a horizontal strip (every trigger shares one `y`) but meant a
// vertical strip JUMPED the pill between rows with no transition,
// since only `x` and `width` were ever animated and `x` never changes
// in a column. Animating `y` unconditionally costs a horizontal list
// nothing: its value is constant, so the spring never runs.
style={{ 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, y: pill.y, 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, orientation } = 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 dark:data-[active]:text-accent-400 text-muted-foreground font-medium transition-[color] duration-150 focus-visible:ring-ring-focus focus-visible:outline-ring inline-flex items-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",
// `flex-1` is HORIZONTAL-ONLY. It means "start from a zero main-axis
// basis and grow to fill", and in a column the main axis is HEIGHT, so
// every trigger in a vertical strip collapsed to a sliver: the list
// takes its height from its triggers, so there was no height to grow
// into. A vertical strip sizes each row to its content and stretches
// them to a common width instead, which `items-stretch` on the list
// already does.
// `justify-between` rather than `justify-start`: the count is a SIBLING
// of the label span, so between pushes it to the trailing edge, which is
// how a column of counts is read (every mail sidebar does this). With no
// count there is only one child, and between puts a lone child at the
// start, so a trigger without one is unaffected and nothing reflows.
orientation === "vertical" ? "justify-between" : "flex-1 justify-center",
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.
// The fill tracks the LABEL's accent step, so it moved to 400 in
// dark when the pill went to accent-800; a chip a step off its own
// label reads as two blues, and the count must not be the dimmer.
// Its text inverts to accent-950 there: accent-foreground on an
// accent-400 fill is 3.09:1, against 5.0:1 on light's accent-500.
// `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 dark:bg-accent-400 dark:text-accent-950"
: "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 }