{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tabs","type":"registry:component","title":"Tabs","description":"Views of one thing: the underline or pill is thrown to the new tab, the panel slides in from the side you moved toward, and counts roll.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/tabs.tsx","type":"registry:component","content":"// Hairline UI · tabs · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * TABS: views of one thing, side by side\n *\n *   pick     the indicator (an underline, or a pill) is thrown to\n *            the new tab while its width eases to the label\n *   panel    the new panel slides in from the side you moved\n *            toward, out of a blur, as the old one leaves the other\n *            way; the box eases to the new panel's height\n *   count    a tab's count rolls when it changes\n *   keys     arrows move and select, Home/End jump; the tablist\n *            scrolls the chosen tab into view on narrow screens\n * ───────────────────────────────────────────────────────── */\n\nexport interface TabItem {\n  value: string;\n  label: React.ReactNode;\n  icon?: React.ReactNode;\n  count?: number;\n  content?: React.ReactNode;\n  disabled?: boolean;\n}\n\nexport interface TabsProps {\n  items: TabItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  variant?: \"underline\" | \"pills\";\n  /** Accessible name of the tablist. */\n  label: string;\n  className?: string;\n}\n\ntype Leaving = { value: string; dir: number; node: React.ReactNode; key: string };\n\nexport function Tabs({ items, value, defaultValue, onValueChange, variant = \"underline\", label, className = \"\" }: TabsProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const [own, setOwn] = React.useState(defaultValue ?? items[0]?.value);\n  const current = value !== undefined ? value : own;\n  const [mark, setMark] = React.useState<{ x: number; w: number } | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const [height, setHeight] = React.useState<number | null>(null);\n  const [shown, setShown] = React.useState(current);\n  const [leaving, setLeaving] = React.useState<Leaving | null>(null);\n  const [dir, setDir] = React.useState(1);\n  const pills = variant === \"pills\";\n\n  // When the value changes, the old panel becomes a leaving copy and the new one enters from `dir`.\n  if (shown !== current) {\n    const from = items.findIndex((i) => i.value === shown);\n    const to = items.findIndex((i) => i.value === current);\n    const d = to >= from ? 1 : -1;\n    setDir(d);\n    setLeaving(reduced ? null : { value: shown, dir: d, node: items[from]?.content, key: `${shown}>${current}` });\n    setShown(current);\n  }\n\n  React.useEffect(() => {\n    if (!leaving) return;\n    const timer = window.setTimeout(() => setLeaving(null), 260);\n    return () => window.clearTimeout(timer);\n  }, [leaving]);\n\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const measure = () => {\n      const el = list.querySelector<HTMLElement>(`[data-tab=\"${CSS.escape(current ?? \"\")}\"]`);\n      if (!el) return setMark(null);\n      const next = { x: el.offsetLeft, w: el.offsetWidth };\n      setMark((p) => (p && p.x === next.x && p.w === next.w ? p : next));\n      // Keep the chosen tab in view inside the scrolling strip, never scrolling the page.\n      const strip = list.parentElement;\n      if (strip && strip.scrollWidth > strip.clientWidth) {\n        const left = el.offsetLeft - 16;\n        const right = el.offsetLeft + el.offsetWidth + 16 - strip.clientWidth;\n        if (strip.scrollLeft > left) strip.scrollTo({ left, behavior: reduced ? \"auto\" : \"smooth\" });\n        else if (strip.scrollLeft < right) strip.scrollTo({ left: right, behavior: reduced ? \"auto\" : \"smooth\" });\n      }\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(list);\n    return () => ro.disconnect();\n  }, [current, items, reduced]);\n\n  React.useEffect(() => {\n    if (!mark || ready) return;\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, [mark, ready]);\n\n  React.useLayoutEffect(() => {\n    const el = panelRef.current;\n    if (!el) return;\n    const measure = () => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [shown]);\n\n  // The entering and leaving panels' motion, played once per change.\n  const leavingRef = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = panelRef.current;\n    if (!el || !leaving) return;\n    leavingRef.current?.animate(\n      [\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        { opacity: 0, transform: `translateX(${-leaving.dir * 20}px)`, filter: \"blur(4px)\" },\n      ],\n      { duration: 200, easing: EASE_OUT, fill: \"forwards\" },\n    );\n    el.animate(\n      [\n        { opacity: 0, transform: `translateX(${dir * 20}px)`, filter: \"blur(4px)\" },\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n      ],\n      { duration: 380, easing: EASE_OUT },\n    );\n  }, [leaving, dir]);\n\n  const pick = (v: string) => {\n    if (value === undefined) setOwn(v);\n    onValueChange?.(v);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const enabled = items.filter((i) => !i.disabled);\n    const index = enabled.findIndex((i) => i.value === current);\n    let next = -1;\n    if (e.key === \"ArrowRight\") next = (index + 1) % enabled.length;\n    else if (e.key === \"ArrowLeft\") next = (index - 1 + enabled.length) % enabled.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = enabled.length - 1;\n    if (next < 0) return;\n    e.preventDefault();\n    pick(enabled[next].value);\n    listRef.current?.querySelector<HTMLElement>(`[data-tab=\"${CSS.escape(enabled[next].value)}\"]`)?.focus();\n  };\n\n  const hasPanels = items.some((i) => i.content !== undefined);\n  const active = items.find((i) => i.value === shown);\n\n  return (\n    <div className={`flex w-full min-w-0 flex-col ${className}`}>\n      <div className={`relative min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${pills ? \"\" : \"shadow-[inset_0_-1px_0_var(--border)]\"}`}>\n        <div ref={listRef} role=\"tablist\" aria-label={label} onKeyDown={onKeyDown} className={`relative flex w-max items-center ${pills ? \"gap-1 rounded-full bg-muted p-1\" : \"gap-5 px-1\"}`}>\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute left-0 ${pills ? \"top-1 bottom-1 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\" : \"bottom-0 h-[2px] rounded-full bg-foreground\"}`}\n            style={{\n              transform: `translateX(${mark?.x ?? 0}px)`,\n              width: mark?.w ?? 0,\n              opacity: mark ? 1 : 0,\n              transition: reduced || !ready ? \"none\" : `transform 440ms ${EASE_THROW}, width 400ms ${EASE_OUT}, opacity 200ms ${EASE_OUT}`,\n            }}\n          />\n          {items.map((item) => {\n            const on = item.value === current;\n            return (\n              <button\n                key={item.value}\n                type=\"button\"\n                role=\"tab\"\n                id={`${baseId}-tab-${item.value}`}\n                aria-selected={on}\n                aria-controls={hasPanels ? `${baseId}-panel` : undefined}\n                tabIndex={on ? 0 : -1}\n                disabled={item.disabled}\n                data-tab={item.value}\n                onClick={() => pick(item.value)}\n                className={`relative z-10 flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[13px] font-medium cursor-pointer transition-colors duration-200 disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${\n                  pills ? \"h-7 rounded-full px-3\" : \"h-10 rounded-md\"\n                } ${on ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"} ${INTERACTION_FOCUS}`}\n              >\n                {item.icon}\n                {item.label}\n                {item.count !== undefined && (\n                  <span className={`rounded-full px-1.5 text-[11px] tabular-nums transition-colors duration-200 ${on ? \"bg-foreground text-background\" : \"bg-muted text-muted-foreground\"} ${pills && !on ? \"bg-background\" : \"\"}`}>\n                    <NumberRoll value={item.count} />\n                  </span>\n                )}\n              </button>\n            );\n          })}\n        </div>\n      </div>\n      {hasPanels && (\n        <div className=\"relative overflow-hidden\" style={{ height: height ?? undefined, transition: reduced ? \"none\" : `height 380ms ${EASE_OUT}` }}>\n          {leaving && (\n            <div\n              key={leaving.key}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-x-0 top-0 pt-4\"\n              ref={leavingRef}\n            >\n              {leaving.node}\n            </div>\n          )}\n          <div ref={panelRef} id={`${baseId}-panel`} role=\"tabpanel\" aria-labelledby={`${baseId}-tab-${shown}`} tabIndex={0} className=\"pt-4 outline-none\">\n            {active?.content}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default Tabs;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/tabs. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}