{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "Switch between related content in the same context.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "@radix-ui/react-tabs",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/tabs/tabs.tsx",
      "type": "registry:component",
      "target": "@components/xzero/tabs/tabs.tsx",
      "content": "\"use client\";\n\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\nimport { createContext, useCallback, useContext, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, RefObject } from \"react\";\nimport { ChevronLeft as NavArrowLeft, ChevronRight as NavArrowRight } from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useReducedMotion } from \"motion/react\";\nimport type { AnimationPlaybackControls, Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./tabs.module.css\";\n\ntype RootProps = ComponentPropsWithoutRef<typeof TabsPrimitive.Root>;\n/** `direction` is +1 when the new tab sits after the old one; `panelHeightRef` holds the visible panel height so the next panel can morph from it; `leavingRectRef` pins the outgoing panel where it was on screen. */\nconst TabsContext = createContext<{ active: string; layoutId: string; direction: number; panelHeightRef: RefObject<number | null>; leavingRectRef: RefObject<DOMRect | null> }>({ active: \"\", layoutId: \"tabs\", direction: 1, panelHeightRef: { current: null }, leavingRectRef: { current: null } });\n\nexport function Tabs({ value, defaultValue, onValueChange, className, ...props }: RootProps) {\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const [direction, setDirection] = useState(1);\n  const active = value ?? internal;\n  const layoutId = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const panelHeightRef = useRef<number | null>(null);\n  const leavingRectRef = useRef<DOMRect | null>(null);\n  function handleChange(next: string) {\n    const frame = root.current;\n    leavingRectRef.current = frame?.querySelector(':scope > [role=\"tabpanel\"][data-state=\"active\"]')?.getBoundingClientRect() ?? null;\n    const order = frame ? Array.from(frame.querySelectorAll<HTMLElement>('[role=\"tab\"][data-value]')).filter(tab => tab.closest(`.${styles.root}`) === frame).map(tab => tab.dataset.value) : [];\n    const from = order.indexOf(active), to = order.indexOf(next);\n    if (from >= 0 && to >= 0 && from !== to) setDirection(to > from ? 1 : -1);\n    if (value === undefined) setInternal(next); onValueChange?.(next);\n  }\n  return <TabsContext.Provider value={{ active, layoutId, direction, panelHeightRef, leavingRectRef }}><LayoutGroup id={layoutId}><TabsPrimitive.Root {...props} ref={root} className={[styles.root, className].filter(Boolean).join(\" \")} value={active} onValueChange={handleChange}/></LayoutGroup></TabsContext.Provider>;\n}\n\nexport function TabsList({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {\n  const { active } = useContext(TabsContext);\n  const reduced = useReducedMotion();\n  const shell = useRef<HTMLDivElement>(null);\n  const viewport = useRef<HTMLDivElement>(null);\n  const list = useRef<HTMLDivElement>(null);\n  const [edges, setEdges] = useState({ overflow: false, left: false, right: false });\n  const update = useCallback(() => {\n    const frame = shell.current;\n    const scroll = viewport.current;\n    if (!frame || !scroll) return;\n    const max = Math.max(0, scroll.scrollWidth - scroll.clientWidth);\n    const next = { overflow: scroll.scrollWidth > frame.clientWidth + 1, left: scroll.scrollLeft > 1, right: scroll.scrollLeft < max - 1 };\n    setEdges(previous => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);\n  }, []);\n  const reveal = useCallback((tab: HTMLElement | null) => {\n    const scroll = viewport.current;\n    if (!scroll || !tab) return;\n    const frame = scroll.getBoundingClientRect();\n    const item = tab.getBoundingClientRect();\n    const max = Math.max(0, scroll.scrollWidth - scroll.clientWidth);\n    const left = frame.left + (scroll.scrollLeft > 1 ? 34 : 0);\n    const right = frame.right - (scroll.scrollLeft < max - 1 ? 34 : 0);\n    const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;\n    if (delta) scroll.scrollBy({ left: delta, behavior: reduced ? \"instant\" : \"smooth\" });\n  }, [reduced]);\n  useLayoutEffect(() => {\n    const frame = shell.current;\n    const scroll = viewport.current;\n    const content = list.current;\n    if (!frame || !scroll || !content) return;\n    const observer = new ResizeObserver(update);\n    observer.observe(frame);\n    observer.observe(scroll);\n    observer.observe(content);\n    scroll.addEventListener(\"scroll\", update, { passive: true });\n    update();\n    return () => { observer.disconnect(); scroll.removeEventListener(\"scroll\", update); };\n  }, [update]);\n  useLayoutEffect(() => { reveal(list.current?.querySelector<HTMLElement>('[role=\"tab\"][data-state=\"active\"]') ?? null); }, [active, reveal]);\n  const scrollTabs = (direction: number) => viewport.current?.scrollBy({ left: direction * (viewport.current?.clientWidth ?? 0) * .75, behavior: reduced ? \"instant\" : \"smooth\" });\n  return <div ref={shell} className={styles.listShell} data-overflow={edges.overflow} data-left={edges.left} data-right={edges.right}>\n    {edges.overflow && <button type=\"button\" className={`${styles.scrollButton} ${styles.scrollLeft}`} aria-label=\"Scroll tabs left\" disabled={!edges.left} onClick={() => scrollTabs(-1)}><NavArrowLeft width={17} height={17} aria-hidden=\"true\"/></button>}\n    <motion.div ref={viewport} layoutScroll className={styles.viewport} onFocusCapture={event => { if (event.target instanceof HTMLElement && event.target.getAttribute(\"role\") === \"tab\") reveal(event.target); }}>\n      <TabsPrimitive.List {...props} ref={list} className={[styles.list, className].filter(Boolean).join(\" \")}/>\n    </motion.div>\n    {edges.overflow && <button type=\"button\" className={`${styles.scrollButton} ${styles.scrollRight}`} aria-label=\"Scroll tabs right\" disabled={!edges.right} onClick={() => scrollTabs(1)}><NavArrowRight width={17} height={17} aria-hidden=\"true\"/></button>}\n  </div>;\n}\n\nexport function TabsTrigger({ className, children, value, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {\n  const { active } = useContext(TabsContext);\n  const reduced = useReducedMotion();\n  // The LayoutGroup in Tabs scopes the highlight to this instance, so it glides between triggers but never flies in from another tab set.\n  return <TabsPrimitive.Trigger {...props} value={value} data-value={value} className={[styles.trigger, className].filter(Boolean).join(\" \")}>\n    {active === value && <motion.span className={styles.selection} layoutId=\"selection\" layoutDependency={active} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden=\"true\"/>}\n    <span className={styles.triggerLabel}>{children}</span>\n  </TabsPrimitive.Trigger>;\n}\n\nconst panelMotion: Variants = {\n  enter: (direction: number) => ({ opacity: 0, x: direction * 8 }),\n  center: { opacity: 1, x: 0, transition: { opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, x: motionTokens.spring.smooth } },\n  exit: (direction: number) => ({ opacity: 0, x: direction * -6, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),\n};\n/** Reduced motion: a short crossfade in place. Keys match panelMotion so server and client render identical styles. */\nconst panelFade: Variants = {\n  enter: { opacity: 0, x: 0 },\n  center: { opacity: 1, x: 0, transition: { duration: motionTokens.duration.instant } },\n  exit: { opacity: 0, x: 0, transition: { duration: .1 } },\n};\n\nexport function TabsContent({ className, value, forceMount, children, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {\n  const { active, direction, panelHeightRef, leavingRectRef } = useContext(TabsContext);\n  const reduced = useReducedMotion();\n  const panel = useRef<HTMLDivElement>(null);\n  const selected = active === value;\n  const classes = [styles.content, className].filter(Boolean).join(\" \");\n  // The incoming panel starts at the outgoing panel's height and settles at its own, so content below glides instead of jumping.\n  useLayoutEffect(() => {\n    const node = panel.current;\n    if (!node || forceMount) return;\n    // Outgoing: the tab root may have changed size around it, so pin the panel to where it was while it fades.\n    if (!selected) { const before = leavingRectRef.current; const now = node.getBoundingClientRect(); if (before) node.style.translate = `${before.left - now.left}px ${before.top - now.top}px`; node.inert = true; return; }\n    node.style.translate = \"\"; node.inert = false;\n    const from = panelHeightRef.current;\n    const to = node.offsetHeight;\n    let controls: AnimationPlaybackControls | undefined;\n    const release = () => { node.style.height = \"\"; node.style.overflow = \"\"; };\n    if (from !== null && Math.abs(from - to) > 1 && !reduced) {\n      if (to > from) node.style.overflow = \"clip\";\n      controls = animate(node, { height: [from, to] }, { ...motionTokens.spring.smooth, onComplete: release });\n    }\n    panelHeightRef.current = controls && from !== null ? from : to;\n    const observer = new ResizeObserver(() => { panelHeightRef.current = node.offsetHeight; });\n    observer.observe(node);\n    return () => { observer.disconnect(); controls?.stop(); release(); };\n  }, [selected, reduced, forceMount, panelHeightRef, leavingRectRef]);\n  if (forceMount) return <TabsPrimitive.Content {...props} value={value} forceMount className={classes}>{children}</TabsPrimitive.Content>;\n  return <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n    {selected && <TabsPrimitive.Content {...props} key={value} value={value} forceMount asChild>\n      <motion.div ref={panel} className={classes} custom={direction} variants={reduced ? panelFade : panelMotion} initial=\"enter\" animate=\"center\" exit=\"exit\">{children}</motion.div>\n    </TabsPrimitive.Content>}\n  </AnimatePresence>;\n}\n"
    },
    {
      "path": "registry/components/tabs/tabs.module.css",
      "type": "registry:component",
      "target": "@components/xzero/tabs/tabs.module.css",
      "content": "/* Positioned so an outgoing panel pops out exactly where it was while the next one takes its place. */\n.root { position: relative; }\n.listShell { position: relative; display: inline-flex; max-width: 100%; min-width: 0; align-items: center; isolation: isolate; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }\n.viewport { min-width: 0; overflow-x: auto; border-radius: inherit; scrollbar-width: none; }\n.viewport::-webkit-scrollbar { display: none; }\n.listShell[data-left=\"true\"] .viewport { mask-image: linear-gradient(to right, transparent, black 35px, black); }\n.listShell[data-right=\"true\"] .viewport { mask-image: linear-gradient(to right, black, black calc(100% - 35px), transparent); }\n.listShell[data-left=\"true\"][data-right=\"true\"] .viewport { mask-image: linear-gradient(to right, transparent, black 35px, black calc(100% - 35px), transparent); }\n/* The list owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */\n.list { isolation: isolate; display: inline-flex; width: max-content; align-items: center; gap: var(--space-1); padding: var(--space-1); }\n.scrollButton { position: absolute; z-index: 2; top: 0; bottom: 0; display: grid; width: 33px; place-items: center; border: 0; background: var(--surface-muted); color: var(--foreground); }\n.scrollLeft { left: 0; border-radius: var(--radius-control) 0 0 var(--radius-control); }\n.scrollRight { right: 0; border-radius: 0 var(--radius-control) var(--radius-control) 0; }\n.scrollButton:disabled { opacity: 0; pointer-events: none; }\n.scrollButton:focus-visible { --focus-outline-offset: -3px; }\n.trigger { position: relative; -webkit-tap-highlight-color: transparent; display: inline-grid; min-width: 5.5rem; place-items: center; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 3px); padding: 0 var(--space-3); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover { color: var(--foreground); } }\n.trigger:active { color: var(--foreground); }.trigger[data-state=\"active\"] { color: var(--accent-strong); }\n.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); box-shadow: var(--shadow-resting); will-change: transform; }\n.triggerLabel { position: relative; z-index: 1; }\n.trigger:disabled { cursor: not-allowed; opacity: .5; }\n.content { margin-top: var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n/* The outgoing panel is lifted out of flow by the presence animation; drop its margin so it stays in place and ignore stray clicks. */\n.content[data-motion-pop-id] { margin-top: 0; }\n.content[data-state=\"inactive\"] { pointer-events: none; }\n.content:focus-visible { --focus-outline-offset: 3px; border-radius: var(--radius-control); }\n@media (prefers-reduced-motion: reduce) { .trigger { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/tabs",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "navigation",
      "layout"
    ],
    "docs": "https://ui.xzero.co.uk/components/tabs",
    "markdown": "https://ui.xzero.co.uk/components/tabs/markdown"
  }
}