{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "Sliding underline or pill, panels that crossfade and glide to height.",
  "categories": [
    "navigation"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/tabs.tsx",
      "content": "\"use client\";\nimport { Tabs as BaseTabs } from \"@base-ui/react/tabs\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\n\ntype Variant = \"underline\" | \"pill\";\ntype Size = \"sm\" | \"md\";\nconst TabsContext = createContext<{ variant: Variant; size: Size }>({ variant: \"underline\", size: \"md\" });\n\n/** Width of the edge fade on an overflowing list, and the scroll padding that keeps the active tab clear of it. */\nconst FADE = 28;\n\n/**\n * Marks an overflowing scroller's edges with CSS variables (--fade-start / --fade-end)\n * and data attributes, straight on the element so scrolling never re-renders React.\n */\nexport function useOverflowEdges(ref: React.RefObject<HTMLElement | null>, fade = FADE) {\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let frame = 0;\n    const update = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        const x = Math.abs(el.scrollLeft);\n        const max = el.scrollWidth - el.clientWidth;\n        const start = x > 1;\n        const end = max - x > 1;\n        el.style.setProperty(\"--fade-start\", start ? `${fade}px` : \"0px\");\n        el.style.setProperty(\"--fade-end\", end ? `${fade}px` : \"0px\");\n        el.toggleAttribute(\"data-overflow-start\", start);\n        el.toggleAttribute(\"data-overflow-end\", end);\n      });\n    };\n    update();\n    el.addEventListener(\"scroll\", update, { passive: true });\n    // Content can change width without the scroller resizing (a count grows, a font loads).\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    for (const child of el.children) ro.observe(child);\n    const mo = new MutationObserver(() => {\n      for (const child of el.children) ro.observe(child);\n      update();\n    });\n    mo.observe(el, { childList: true });\n    return () => {\n      cancelAnimationFrame(frame);\n      el.removeEventListener(\"scroll\", update);\n      ro.disconnect();\n      mo.disconnect();\n    };\n  }, [ref, fade]);\n}\n\n/** Scrolls the list sideways only, so a tab list low on the page never drags the page with it. */\nfunction revealActive(list: HTMLElement, smooth: boolean) {\n  const tab = list.querySelector<HTMLElement>(\"[role=tab][data-active]\");\n  if (!tab || list.scrollWidth <= list.clientWidth) return;\n  const left = tab.offsetLeft - FADE;\n  const right = tab.offsetLeft + tab.offsetWidth + FADE - list.clientWidth;\n  const target = list.scrollLeft > left ? left : list.scrollLeft < right ? right : null;\n  if (target !== null) list.scrollTo({ left: target, behavior: smooth ? \"smooth\" : \"auto\" });\n}\n\nexport type TabsProps = Omit<BaseTabs.Root.Props, \"className\" | \"orientation\"> & {\n  /** A hairline with a sliding underline, or a soft pill that slides behind the active tab. */\n  variant?: Variant;\n  size?: Size;\n  className?: string;\n};\n\nexport function Tabs({ variant = \"underline\", size = \"md\", className, onKeyDownCapture, onPointerDownCapture, ...rest }: TabsProps) {\n  return (\n    <TabsContext value={{ variant, size }}>\n      <BaseTabs.Root\n        data-tabs-root=\"\"\n        data-variant={variant}\n        data-size={size}\n        // Arrow keys switch tabs on the same frame; only pointer switches animate.\n        onKeyDownCapture={(e) => {\n          e.currentTarget.dataset.nav = \"key\";\n          onKeyDownCapture?.(e);\n        }}\n        onPointerDownCapture={(e) => {\n          e.currentTarget.dataset.nav = \"pointer\";\n          onPointerDownCapture?.(e);\n        }}\n        className={cn(\"group/tabs flex min-w-0 flex-col\", className)}\n        {...rest}\n      />\n    </TabsContext>\n  );\n}\n\nexport type TabsListProps = Omit<BaseTabs.List.Props, \"className\"> & {\n  className?: string;\n  /** Classes for the element that wraps the scroller and draws the hairline. */\n  wrapperClassName?: string;\n};\n\nexport function TabsList({ className, wrapperClassName, children, activateOnFocus = true, ...rest }: TabsListProps) {\n  const { variant, size } = use(TabsContext);\n  const ref = useRef<HTMLDivElement>(null);\n  useOverflowEdges(ref);\n\n  // Keep the active tab in view: on mount without motion, afterwards with it.\n  useEffect(() => {\n    const list = ref.current;\n    if (!list) return;\n    revealActive(list, false);\n    const mo = new MutationObserver(() => {\n      const root = list.closest<HTMLElement>(\"[data-tabs-root]\");\n      const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n      revealActive(list, root?.dataset.nav !== \"key\" && !reduce);\n    });\n    mo.observe(list, { subtree: true, attributeFilter: [\"data-active\"] });\n    return () => mo.disconnect();\n  }, []);\n\n  const underline = variant === \"underline\";\n\n  return (\n    <div\n      className={cn(\n        \"relative min-w-0\",\n        // The hairline lives on the wrapper so it stays put while the tabs scroll over it.\n        underline && \"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-line\",\n        wrapperClassName,\n      )}\n    >\n      <BaseTabs.List\n        ref={ref}\n        activateOnFocus={activateOnFocus}\n        className={cn(\n          \"relative flex min-w-0 items-center overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          \"[mask-image:linear-gradient(to_right,transparent,var(--fg)_var(--fade-start,0px),var(--fg)_calc(100%-var(--fade-end,0px)),transparent)]\",\n          \"scroll-px-7\",\n          // Pill tabs draw at 28–32px; on touch the list lends them 8px above and below without moving anything.\n          underline ? \"gap-1\" : \"gap-0.5 pointer-coarse:-my-2 pointer-coarse:py-2\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n        <BaseTabs.Indicator\n          className={cn(\n            \"pointer-events-none absolute left-0 translate-x-(--active-tab-left)\",\n            \"transition-[translate,width] duration-[240ms] ease-in-out-quart group-data-[nav=key]/tabs:duration-0\",\n            underline\n              ? // Inset to the label so the line sits under words, not padding.\n                cn(\"bottom-0 z-10 h-0.5 rounded-full bg-fg\", size === \"sm\" ? \"ml-1.5 w-[calc(var(--active-tab-width)-12px)]\" : \"ml-2 w-[calc(var(--active-tab-width)-16px)]\")\n              : cn(\n                  \"top-(--active-tab-top) -z-10 h-(--active-tab-height) w-(--active-tab-width) bg-fg/[0.075] ring-1 ring-inset ring-fg/[0.04]\",\n                  size === \"sm\" ? \"rounded-md\" : \"rounded-lg\",\n                ),\n          )}\n        />\n      </BaseTabs.List>\n    </div>\n  );\n}\n\nexport type TabsTabProps = Omit<BaseTabs.Tab.Props, \"className\" | \"children\"> & {\n  className?: string;\n  children?: React.ReactNode;\n  /** 16px icon before the label (14px at sm). */\n  icon?: React.ReactNode;\n  /** A number after the label. It rolls when it changes. */\n  count?: number;\n  /** A small dot that says something in this tab changed since it was last seen. */\n  dot?: boolean;\n};\n\nexport function TabsTab({ icon, count, dot = false, children, className, ...rest }: TabsTabProps) {\n  const { variant, size } = use(TabsContext);\n  const reduce = useReducedMotion();\n  const underline = variant === \"underline\";\n  const sm = size === \"sm\";\n\n  return (\n    <BaseTabs.Tab\n      className={cn(\n        \"group/tab relative flex shrink-0 select-none items-center outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n        \"font-medium tracking-[-0.005em] text-fg-3 transition-colors duration-150 hover:text-fg-2 data-active:text-fg\",\n        \"data-disabled:pointer-events-none data-disabled:text-fg-4\",\n        underline ? (sm ? \"h-9 px-1.5 text-[12.5px]\" : \"h-10 px-2 text-[13px]\") : sm ? \"h-7 rounded-md px-2.5 text-[12.5px]\" : \"h-8 rounded-lg px-3 text-[13px]\",\n        // Pill tabs ring the pill; underline tabs ring the label so the ring clears the scroller's edge.\n        !underline && \"pointer-coarse:before:absolute pointer-coarse:before:inset-x-0 pointer-coarse:before:-inset-y-2 pointer-coarse:before:content-['']\",\n        !underline && \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <span\n        className={cn(\n          \"flex items-center whitespace-nowrap transition-[scale] duration-150 ease-out-quart group-active/tab:scale-[0.97] group-active/tab:duration-75\",\n          sm ? \"gap-1.5 [&_svg]:size-3.5\" : \"gap-2 [&_svg]:size-4\",\n          underline &&\n            cn(\n              \"rounded-md outline-offset-2 group-focus-visible/tab:outline-solid group-focus-visible/tab:outline-1 group-focus-visible/tab:outline-fg-3\",\n              sm ? \"-mx-1 px-1\" : \"-mx-1 px-1\",\n            ),\n        )}\n      >\n        {icon && <span className=\"-ml-0.5 grid shrink-0 place-items-center text-fg-4 transition-colors duration-150 group-hover/tab:text-fg-3 group-data-active/tab:text-fg-2\">{icon}</span>}\n        {children}\n        {count !== undefined && (\n          <>\n            <span\n              aria-hidden\n              className={cn(\n                \"tabular inline-flex min-w-[18px] items-center justify-center rounded-full bg-fg/[0.06] px-1.5 text-[11px] font-medium leading-none\",\n                \"text-fg-3 transition-colors duration-150 group-data-active/tab:bg-fg/[0.09] group-data-active/tab:text-fg-2\",\n                sm ? \"h-4\" : \"h-[18px]\",\n              )}\n            >\n              <NumberFlow\n                value={count}\n                transformTiming={{ duration: reduce ? 0 : 420, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }}\n                spinTiming={{ duration: reduce ? 0 : 420, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }}\n                opacityTiming={{ duration: reduce ? 150 : 250, easing: \"ease-out\" }}\n              />\n            </span>\n            <span className=\"sr-only\">({count})</span>\n          </>\n        )}\n        <AnimatePresence initial={false}>\n          {dot && (\n            <motion.span\n              key=\"dot\"\n              aria-hidden\n              className=\"size-1.5 shrink-0 rounded-full bg-fg-2\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0, transition: { duration: 0.12 } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            />\n          )}\n        </AnimatePresence>\n        {dot && <span className=\"sr-only\">, updated</span>}\n      </span>\n    </BaseTabs.Tab>\n  );\n}\n\nexport type TabsPanelsProps = React.ComponentProps<\"div\"> & {\n  /** Glide the height between panels of different lengths instead of snapping. */\n  animateHeight?: boolean;\n};\n\n/**\n * Stacks the panels in one grid cell so the outgoing and incoming panel can\n * crossfade in place, and eases the height from one panel to the next.\n */\nexport function TabsPanels({ animateHeight = true, className, children, style, ...rest }: TabsPanelsProps) {\n  const outer = useRef<HTMLDivElement>(null);\n  const inner = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const o = outer.current;\n    const i = inner.current;\n    if (!animateHeight || !o || !i) return;\n    let first = true;\n    let settle = 0;\n    const ro = new ResizeObserver(() => {\n      const h = i.offsetHeight;\n      const root = o.closest<HTMLElement>(\"[data-tabs-root]\");\n      if (first || root?.dataset.nav === \"key\") {\n        o.style.transitionDuration = \"0ms\";\n        first = false;\n      } else {\n        o.style.transitionDuration = \"\";\n        // Clip only while the height moves, so focus rings inside are never cut off at rest.\n        o.style.overflow = \"clip\";\n        window.clearTimeout(settle);\n        settle = window.setTimeout(() => (o.style.overflow = \"\"), 320);\n      }\n      o.style.height = `${h}px`;\n    });\n    ro.observe(i);\n    return () => {\n      ro.disconnect();\n      window.clearTimeout(settle);\n    };\n  }, [animateHeight]);\n\n  return (\n    <div\n      ref={outer}\n      // Content-box sizing: the measured height is the panels' own, padding sits outside it.\n      className={cn(\"relative box-content transition-[height] duration-[260ms] ease-in-out-quart\", className)}\n      style={style}\n      {...rest}\n    >\n      <div ref={inner} className=\"grid grid-cols-1\">\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport type TabsPanelProps = Omit<BaseTabs.Panel.Props, \"className\"> & { className?: string };\n\nexport function TabsPanel({ className, ...rest }: TabsPanelProps) {\n  return (\n    <BaseTabs.Panel\n      className={cn(\n        \"col-start-1 row-start-1 min-w-0 rounded-md outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-fg-3\",\n        // Arrives from the side of the tab that was picked; leaves by fading out of the flow.\n        \"transition-[opacity,translate] duration-[220ms] ease-out-expo\",\n        \"data-starting-style:opacity-0 data-starting-style:data-[activation-direction=right]:translate-x-2 data-starting-style:data-[activation-direction=left]:-translate-x-2\",\n        \"data-ending-style:pointer-events-none data-ending-style:absolute data-ending-style:inset-x-0 data-ending-style:top-0 data-ending-style:opacity-0 data-ending-style:duration-[120ms] data-ending-style:ease-out-quart\",\n        \"group-data-[nav=key]/tabs:duration-0\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/tabs"
}
