{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "Accessible 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.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/tabs.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { blur, duration, ease, spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { type AnimationPlaybackControls, animate, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { Tabs as TabsPrimitive } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  createContext,\n  type Ref,\n  type RefObject,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Tabs, built on Radix Tabs (roadmap, 2026-10-16: \"Panels switched by an underline that slides between tabs\").\n * - The indicator slides to the active tab (layoutId on spring.snappy, .agents/rules/motion.md), scoped to each\n *   list, so two tab sets never trade it. Two looks: `underline` (the default), a 2px line riding the list's\n *   hairline, and `pill`, Switcher's filled pill on a muted track.\n * - The panel that appears comes through the blur and slides 12px in from the side of the tab you moved to\n *   (from the right when you move right), while its height glides from the old panel's to its own:\n *   duration.slow on ease.out, never a spring, clipped only while it moves. The filter and height are cleared\n *   afterwards. The old panel goes at once, so the two never overlap.\n * - Nothing animates on first paint. With reduced motion the indicator jumps and the panel only fades.\n * - A tablist of tabs, each controlling a tabpanel. Arrow keys move between tabs and show their panel; Home\n *   and End jump to the ends.\n */\n\ntype Variant = \"underline\" | \"pill\";\n\n/** How far the new panel slides in, in px */\nconst SLIDE = 12;\n/** The inline styles an entrance leaves on a panel, cleared when it's hidden again */\nconst LEFTOVERS = [\"opacity\", \"transform\", \"filter\", \"height\", \"overflow\"];\n\ntype TabsContextValue = {\n  value: string;\n  reduce: boolean;\n  root: RefObject<HTMLDivElement | null>;\n  /** The tab shown before the latest change. The root updates it after the panels' effects have read it. */\n  previous: RefObject<string>;\n  /** The shown panel's height, kept current, so the next panel glides from it */\n  height: RefObject<number>;\n};\n\nconst TabsContext = createContext<TabsContextValue | null>(null);\nconst ListContext = createContext<{ variant: Variant }>({ variant: \"underline\" });\n\n/** Hands a node to our ref and to the caller's */\nfunction useMergedRef<T>(own: { current: T | null }, theirs?: Ref<T>) {\n  return (node: T | null) => {\n    own.current = node;\n    if (typeof theirs === \"function\") theirs(node);\n    else if (theirs) (theirs as { current: T | null }).current = node;\n  };\n}\n\n/** A tab of this set (not of a set nested inside one of its panels) */\nfunction triggerOf(root: HTMLElement, value: string) {\n  return Array.from(root.querySelectorAll<HTMLElement>(\"[data-slot=tabs-trigger]\")).find(\n    (trigger) => trigger.dataset.value === value && trigger.closest(\"[data-slot=tabs]\") === root,\n  );\n}\n\n/**\n * Panels of related content, one shown at a time, built on Radix Tabs. Holds the value (controlled or not)\n * so the indicator and the panels know which tab is shown.\n */\nfunction Tabs({\n  value,\n  defaultValue,\n  onValueChange,\n  className,\n  ref,\n  ...props\n}: ComponentProps<typeof TabsPrimitive.Root>) {\n  const reduce = useReducedMotion() ?? false;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const current = value ?? internal;\n  const root = useRef<HTMLDivElement>(null);\n  const setRef = useMergedRef(root, ref);\n  const previous = useRef(current);\n  const height = useRef(0);\n\n  // Parents' layout effects run after their children's, so the panel that just appeared has already read the\n  // tab it replaced\n  useLayoutEffect(() => {\n    previous.current = current;\n  }, [current]);\n\n  return (\n    <TabsContext.Provider value={{ value: current, reduce, root, previous, height }}>\n      <TabsPrimitive.Root\n        ref={setRef}\n        data-slot=\"tabs\"\n        value={current}\n        onValueChange={(next) => {\n          setInternal(next);\n          onValueChange?.(next);\n        }}\n        className={cn(\"flex flex-col gap-4\", className)}\n        {...props}\n      />\n    </TabsContext.Provider>\n  );\n}\n\nconst tabsListVariants = cva(\"relative isolate flex items-center\", {\n  variants: {\n    variant: {\n      // The hairline is the track the underline rides on, and divides the tabs from the panel\n      underline: \"gap-4 border-b border-border\",\n      // The tabs sit 2px inside the track, so the track is a control height (36px)\n      pill: \"w-fit gap-0.5 rounded-lg bg-muted p-0.5\",\n    },\n  },\n  defaultVariants: { variant: \"underline\" },\n});\n\nconst tabsTriggerVariants = cva(\n  [\n    \"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap\",\n    // The label's colour changes over the indicator's time, so the two arrive together\n    \"text-muted-foreground transition-colors duration-(--duration-base) ease-out hover:text-foreground data-[state=active]:text-foreground\",\n    \"outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n    \"disabled:pointer-events-none disabled:opacity-(--opacity-disabled)\",\n    \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)\",\n  ],\n  {\n    variants: {\n      variant: {\n        underline: \"h-(--size-control-md) px-1\",\n        pill: \"h-8 px-3\",\n      },\n    },\n    defaultVariants: { variant: \"underline\" },\n  },\n);\n\nexport type TabsListProps = ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>;\n\n/** The row of tabs. Give it a name with aria-label when the page has more than one set. */\nfunction TabsList({ className, variant, children, ...props }: TabsListProps) {\n  const groupId = useId();\n  return (\n    <ListContext.Provider value={{ variant: variant ?? \"underline\" }}>\n      <TabsPrimitive.List\n        data-slot=\"tabs-list\"\n        data-variant={variant ?? \"underline\"}\n        className={cn(tabsListVariants({ variant }), className)}\n        {...props}\n      >\n        <LayoutGroup id={groupId}>{children}</LayoutGroup>\n      </TabsPrimitive.List>\n    </ListContext.Provider>\n  );\n}\n\nfunction TabsTrigger({\n  className,\n  value,\n  children,\n  ref,\n  ...props\n}: ComponentProps<typeof TabsPrimitive.Trigger>) {\n  const tabs = useContext(TabsContext);\n  const { variant } = useContext(ListContext);\n  const node = useRef<HTMLButtonElement>(null);\n  const setRef = useMergedRef(node, ref);\n  const [radius, setRadius] = useState<number>();\n\n  // A numeric radius lets motion keep the pill's corners true while it changes width between tabs. Until it's\n  // read (server HTML), the class gives the same corners.\n  useLayoutEffect(() => {\n    if (variant === \"pill\" && node.current)\n      setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || undefined);\n  }, [variant]);\n\n  return (\n    <TabsPrimitive.Trigger\n      ref={setRef}\n      data-slot=\"tabs-trigger\"\n      data-value={value}\n      value={value}\n      className={cn(tabsTriggerVariants({ variant }), className)}\n      {...props}\n    >\n      {tabs?.value === value ? (\n        // Tabs aren't stacking contexts: the indicator joins the list's layer (it's `isolate`), so the pill\n        // passes behind every label as it slides\n        <motion.span\n          layoutId=\"tabs-indicator\"\n          aria-hidden\n          data-slot=\"tabs-indicator\"\n          className={\n            variant === \"pill\"\n              ? \"pointer-events-none absolute inset-0 -z-10 rounded-md bg-background\"\n              : // On the list's hairline: its lower pixel covers it\n                \"pointer-events-none absolute inset-x-0 -bottom-px h-0.5 bg-foreground\"\n          }\n          style={radius ? { borderRadius: radius } : undefined}\n          transition={tabs.reduce ? { duration: 0 } : spring.snappy}\n        />\n      ) : null}\n      {children}\n    </TabsPrimitive.Trigger>\n  );\n}\n\nexport type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>;\n\n/**\n * One tab's panel. Hidden panels render nothing, unless `forceMount` keeps their content (and its state)\n * mounted while hidden.\n */\nfunction TabsContent({ className, value, forceMount, children, ref, ...props }: TabsContentProps) {\n  const tabs = useContext(TabsContext);\n  const node = useRef<HTMLDivElement>(null);\n  const setRef = useMergedRef(node, ref);\n  const active = tabs?.value === value;\n\n  // The panel is shown or hidden in the same commit as the tab changes (Radix's Presence would show it one\n  // render later), so its entrance can measure it and start before the first paint, never flashing in place\n  // biome-ignore lint/correctness/useExhaustiveDependencies: only the panel being shown or hidden starts or stops its entrance\n  useLayoutEffect(() => {\n    const panel = node.current;\n    if (!tabs || !active || !panel) return;\n    const { root, previous, height, reduce } = tabs;\n\n    // The shown panel's height, mid-glide too, so the next panel glides from wherever this one is\n    const observer = new ResizeObserver(() => {\n      height.current = panel.offsetHeight;\n    });\n    observer.observe(panel);\n\n    const running: AnimationPlaybackControls[] = [];\n    const from = previous.current;\n    // On first paint the panel is simply there\n    if (from !== value) {\n      const box = root.current;\n      const before = box ? triggerOf(box, from) : undefined;\n      const after = box ? triggerOf(box, value) : undefined;\n      // From the right when the new tab is to the right of the old one. Offsets, so right-to-left pages and\n      // zoomed previews work too.\n      const side = before && after && after.offsetLeft < before.offsetLeft ? -1 : 1;\n      const start = height.current;\n      const end = panel.offsetHeight;\n\n      if (reduce) {\n        running.push(animate(panel, { opacity: [0, 1] }, { duration: duration.base, ease: ease.out }));\n      } else {\n        const entrance = animate(\n          panel,\n          { opacity: [0, 1], x: [side * SLIDE, 0], filter: [blur.hidden, blur.shown] },\n          { duration: duration.base, ease: ease.out },\n        );\n        // A filter left behind would make a stacking context and a containing block round the content\n        entrance.then(() => panel.style.removeProperty(\"filter\"));\n        running.push(entrance);\n\n        if (start > 0 && end > 0 && start !== end) {\n          panel.style.overflow = \"hidden\";\n          const glide = animate(panel, { height: [start, end] }, { duration: duration.slow, ease: ease.out });\n          glide.then(() => {\n            panel.style.removeProperty(\"height\");\n            panel.style.removeProperty(\"overflow\");\n          });\n          running.push(glide);\n        }\n      }\n    }\n\n    // Hidden again, mid-entrance or not: stop, and leave nothing behind for the next time it's shown\n    return () => {\n      observer.disconnect();\n      for (const animation of running) animation.stop();\n      for (const property of LEFTOVERS) panel.style.removeProperty(property);\n    };\n  }, [active]);\n\n  return (\n    <TabsPrimitive.Content\n      ref={setRef}\n      data-slot=\"tabs-content\"\n      value={value}\n      // We decide what's shown and mounted, so the panel changes in the same render as its tab\n      forceMount\n      hidden={!active}\n      // No flex-1 (unlike shadcn): a flex-basis would override the height the glide sets. Add it to fill a\n      // fixed-height Tabs.\n      className={cn(\n        \"rounded-md outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n        className,\n      )}\n      {...props}\n    >\n      {active || forceMount ? children : null}\n    </TabsPrimitive.Content>\n  );\n}\n\nexport { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants, tabsTriggerVariants };\n"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/tabs"
}
