{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/tabs/tabs.tsx","type":"registry:component","target":"@components/arc/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;\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; leavingRectRef: RefObject }>({ 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(null);\n const panelHeightRef = useRef(null);\n const leavingRectRef = useRef(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('[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 ;\n}\n\nexport function TabsList({ className, ...props }: ComponentPropsWithoutRef) {\n const { active } = useContext(TabsContext);\n const reduced = useReducedMotion();\n const shell = useRef(null);\n const viewport = useRef(null);\n const list = useRef(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('[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
\n {edges.overflow && }\n { if (event.target instanceof HTMLElement && event.target.getAttribute(\"role\") === \"tab\") reveal(event.target); }}>\n \n \n {edges.overflow && }\n
;\n}\n\nexport function TabsTrigger({ className, children, value, ...props }: ComponentPropsWithoutRef) {\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 {active === value && }\n {children}\n ;\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) {\n const { active, direction, panelHeightRef, leavingRectRef } = useContext(TabsContext);\n const reduced = useReducedMotion();\n const panel = useRef(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 {children};\n return \n {selected && \n {children}\n }\n ;\n}\n"},{"path":"registry/components/tabs/tabs.module.css","type":"registry:component","target":"@components/arc/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 { outline: 2px solid var(--focus-ring); 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:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }\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 { outline: 2px solid var(--focus-ring); outline-offset: 3px; border-radius: var(--radius-control); }\n@media (prefers-reduced-motion: reduce) { .trigger { transition: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/tabs","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/tabs","markdown":"https://uiarc.dev/components/tabs/markdown","tags":["navigation","layout"]}}