{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\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<{\n  active: string;\n  layoutId: string;\n  direction: number;\n  panelHeightRef: RefObject<number | null>;\n  leavingRectRef: RefObject<DOMRect | null>;\n}>({\n  active: \"\",\n  layoutId: \"tabs\",\n  direction: 1,\n  panelHeightRef: { current: null },\n  leavingRectRef: { current: null },\n});\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 =\n      frame?.querySelector(':scope > [role=\"tabpanel\"][data-state=\"active\"]')?.getBoundingClientRect() ?? null;\n    // Tabs inside one of this set's own panels belong to a nested set, so they stay out of the order.\n    const order = frame\n      ? Array.from(frame.querySelectorAll<HTMLElement>('[role=\"tab\"][data-value]'))\n          .filter((tab) => {\n            const panel = tab.closest('[role=\"tabpanel\"]');\n            return !panel || !frame.contains(panel);\n          })\n          .map((tab) => tab.dataset.value)\n      : [];\n    const from = order.indexOf(active),\n      to = order.indexOf(next);\n    if (from >= 0 && to >= 0 && from !== to) setDirection(to > from ? 1 : -1);\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  }\n  // Positioned so an outgoing panel pops out exactly where it was while the next one takes its place.\n  return (\n    <TabsContext.Provider value={{ active, layoutId, direction, panelHeightRef, leavingRectRef }}>\n      <LayoutGroup id={layoutId}>\n        <TabsPrimitive.Root\n          {...props}\n          ref={root}\n          className={cn(\"relative\", className)}\n          value={active}\n          onValueChange={handleChange}\n        />\n      </LayoutGroup>\n    </TabsContext.Provider>\n  );\n}\n\nconst scrollButton =\n  \"absolute inset-y-0 z-2 grid w-[33px] place-items-center border-0 bg-hover text-ink focus-visible:[--focus-outline-offset:-3px] disabled:pointer-events-none disabled:opacity-0\";\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 = {\n      overflow: scroll.scrollWidth > frame.clientWidth + 1,\n      left: scroll.scrollLeft > 1,\n      right: scroll.scrollLeft < max - 1,\n    };\n    setEdges((previous) =>\n      previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right\n        ? previous\n        : next,\n    );\n  }, []);\n  const reveal = useCallback(\n    (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    },\n    [reduced],\n  );\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 () => {\n      observer.disconnect();\n      scroll.removeEventListener(\"scroll\", update);\n    };\n  }, [update]);\n  useLayoutEffect(() => {\n    reveal(list.current?.querySelector<HTMLElement>('[role=\"tab\"][data-state=\"active\"]') ?? null);\n  }, [active, reveal]);\n  // The viewport fades out under the edge it can still scroll toward.\n  const mask =\n    edges.left && edges.right\n      ? \"[mask-image:linear-gradient(to_right,transparent,black_35px,black_calc(100%_-_35px),transparent)]\"\n      : edges.left\n        ? \"[mask-image:linear-gradient(to_right,transparent,black_35px,black)]\"\n        : edges.right\n          ? \"[mask-image:linear-gradient(to_right,black,black_calc(100%_-_35px),transparent)]\"\n          : undefined;\n  const scrollTabs = (direction: number) =>\n    viewport.current?.scrollBy({\n      left: direction * (viewport.current?.clientWidth ?? 0) * 0.75,\n      behavior: reduced ? \"instant\" : \"smooth\",\n    });\n  return (\n    <div\n      ref={shell}\n      className=\"relative isolate inline-flex max-w-full min-w-0 items-center rounded-button border border-line bg-hover\"\n      data-overflow={edges.overflow}\n      data-left={edges.left}\n      data-right={edges.right}\n    >\n      {edges.overflow && (\n        <button\n          type=\"button\"\n          className={cn(scrollButton, \"left-0 rounded-l-button\")}\n          aria-label=\"Scroll tabs left\"\n          disabled={!edges.left}\n          onClick={() => scrollTabs(-1)}\n        >\n          <NavArrowLeft width={17} height={17} aria-hidden=\"true\" />\n        </button>\n      )}\n      <motion.div\n        ref={viewport}\n        layoutScroll\n        className={cn(\n          \"min-w-0 overflow-x-auto rounded-[inherit] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          mask,\n        )}\n        onFocusCapture={(event) => {\n          if (event.target instanceof HTMLElement && event.target.getAttribute(\"role\") === \"tab\") reveal(event.target);\n        }}\n      >\n        {/* The list owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */}\n        <TabsPrimitive.List\n          {...props}\n          ref={list}\n          className={cn(\"isolate inline-flex w-max items-center gap-1 p-1\", className)}\n        />\n      </motion.div>\n      {edges.overflow && (\n        <button\n          type=\"button\"\n          className={cn(scrollButton, \"right-0 rounded-r-button\")}\n          aria-label=\"Scroll tabs right\"\n          disabled={!edges.right}\n          onClick={() => scrollTabs(1)}\n        >\n          <NavArrowRight width={17} height={17} aria-hidden=\"true\" />\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport function TabsTrigger({\n  className,\n  children,\n  value,\n  ...props\n}: 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 (\n    <TabsPrimitive.Trigger\n      {...props}\n      value={value}\n      data-value={value}\n      className={cn(\n        \"relative inline-grid min-h-control-sm min-w-[5.5rem] flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent px-3 text-(length:--text-sm) font-medium whitespace-nowrap text-ink-3 [-webkit-tap-highlight-color:transparent] transition-[color] duration-fast ease-standard hover:text-ink active:text-ink disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-accent-ink motion-reduce:transition-none\",\n        className,\n      )}\n    >\n      {active === value && (\n        <motion.span\n          className=\"absolute inset-0 -z-1 rounded-[inherit] border border-line bg-surface shadow-resting will-change-transform\"\n          layoutId=\"selection\"\n          layoutDependency={active}\n          transition={reduced ? { duration: 0 } : motionTokens.spring.morph}\n          aria-hidden=\"true\"\n        />\n      )}\n      <span className=\"relative z-1\">{children}</span>\n    </TabsPrimitive.Trigger>\n  );\n}\n\nconst panelMotion: Variants = {\n  enter: (direction: number) => ({ opacity: 0, x: direction * 8 }),\n  center: {\n    opacity: 1,\n    x: 0,\n    transition: {\n      opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n      x: motionTokens.spring.smooth,\n    },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    x: direction * -6,\n    transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n  }),\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: 0.1 } },\n};\n\nexport function TabsContent({\n  className,\n  value,\n  forceMount,\n  children,\n  ...props\n}: 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  // An outgoing panel is lifted out of flow by the presence animation (data-motion-pop-id); it drops its margin so it stays in place and ignores stray clicks.\n  const classes = cn(\n    \"mt-4 text-sm leading-body text-ink-2 focus-visible:rounded-control focus-visible:[--focus-outline-offset:3px] data-motion-pop-id:mt-0 data-[state=inactive]:pointer-events-none\",\n    className,\n  );\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) {\n      const before = leavingRectRef.current;\n      const now = node.getBoundingClientRect();\n      if (before) node.style.translate = `${before.left - now.left}px ${before.top - now.top}px`;\n      node.inert = true;\n      return;\n    }\n    node.style.translate = \"\";\n    node.inert = false;\n    const from = panelHeightRef.current;\n    const to = node.offsetHeight;\n    let controls: AnimationPlaybackControls | undefined;\n    const release = () => {\n      node.style.height = \"\";\n      node.style.overflow = \"\";\n    };\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(() => {\n      panelHeightRef.current = node.offsetHeight;\n    });\n    observer.observe(node);\n    return () => {\n      observer.disconnect();\n      controls?.stop();\n      release();\n    };\n  }, [selected, reduced, forceMount, panelHeightRef, leavingRectRef]);\n  if (forceMount)\n    return (\n      <TabsPrimitive.Content {...props} value={value} forceMount className={classes}>\n        {children}\n      </TabsPrimitive.Content>\n    );\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n      {selected && (\n        <TabsPrimitive.Content {...props} key={value} value={value} forceMount asChild>\n          <motion.div\n            ref={panel}\n            className={classes}\n            custom={direction}\n            variants={reduced ? panelFade : panelMotion}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n          >\n            {children}\n          </motion.div>\n        </TabsPrimitive.Content>\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/tabs/tabs.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/tabs",
  "meta": {
    "kind": "component",
    "group": "Overlays & disclosure",
    "export": "Tabs",
    "tags": [
      "navigation",
      "layout"
    ],
    "docs": "https://irisui.dev/components/tabs"
  }
}
