{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrollable-tabs",
  "type": "registry:ui",
  "title": "Scrollable Tabs",
  "description": "A tab strip for many tabs: edge fades and scroll buttons, a sliding indicator, wheel-to-horizontal scrolling and closable tabs.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/scrollable-tabs.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport interface ScrollableTabItem {\n  /** Unique value identifying the tab. */\n  value: string;\n  /** Visible tab label. */\n  label: React.ReactNode;\n  /** Plain-text name used for the close button and announcements when `label` is not a string. */\n  textLabel?: string;\n  /** Optional leading icon. */\n  icon?: React.ReactNode;\n  /** Optional panel content. When any item has content, tab panels are rendered. */\n  content?: React.ReactNode;\n  /** Overrides the component-level `closable` for this tab. */\n  closable?: boolean;\n  disabled?: boolean;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface ScrollableTabsProps\n  extends Omit<\n    React.HTMLAttributes<HTMLDivElement>,\n    \"defaultValue\" | \"onChange\" | \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  items: ScrollableTabItem[];\n  /** Selected value (controlled). */\n  value?: string;\n  /** Initially selected value (uncontrolled). Defaults to the first enabled tab. */\n  defaultValue?: string;\n  /** Called when the selection changes. */\n  onValueChange?: (value: string) => void;\n  /** Shows a close glyph on every tab and enables the Delete key. Remove the item in `onClose`. */\n  closable?: boolean;\n  /** Called with the value of a tab the user closed. If it was selected, a neighbour is selected first. */\n  onClose?: (value: string) => void;\n  /** Indicator style. */\n  variant?: \"underline\" | \"pill\";\n  /** \"automatic\" selects on arrow-key focus; \"manual\" waits for Enter / Space. */\n  activation?: \"automatic\" | \"manual\";\n  /** Show scroll buttons at the edges while the strip overflows. */\n  showScrollButtons?: boolean;\n  /** Fade the strip out at the edges that can scroll. */\n  fadeEdges?: boolean;\n  /** Turn vertical mouse-wheel movement into horizontal scrolling while the strip can scroll. */\n  wheelScroll?: boolean;\n  /** Spring for the sliding indicator. */\n  spring?: Spring;\n  /** Indicator and focus colour. */\n  accent?: string;\n  /** Accessible name of the tab list. */\n  label?: string;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst FADE = 48; // px faded at a scrollable edge (also the room kept for the scroll buttons)\n\nconst textOf = (item: ScrollableTabItem) =>\n  item.textLabel ?? (typeof item.label === \"string\" || typeof item.label === \"number\" ? String(item.label) : item.value);\n\n/**\n * A tab strip for many tabs. It scrolls horizontally (touch, trackpad, wheel or edge buttons), fades the\n * edges that can scroll, keeps the selected tab in view by setting scrollLeft, and slides a shared-layout\n * indicator between tabs. Implements the WAI-ARIA tabs pattern with roving tabindex; tabs can be closable.\n */\nexport function ScrollableTabs({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  closable = false,\n  onClose,\n  variant = \"underline\",\n  activation = \"automatic\",\n  showScrollButtons = true,\n  fadeEdges = true,\n  wheelScroll = true,\n  spring = { stiffness: 420, damping: 36, mass: 0.8 },\n  accent = \"#FF7448\",\n  label = \"Tabs\",\n  className = \"\",\n  style,\n  ...props\n}: ScrollableTabsProps) {\n  const id = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const pendingFocusRef = React.useRef<string | null>(null);\n  const didMountRef = React.useRef(false);\n  const [edges, setEdges] = React.useState({ start: false, end: false });\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const enabled = items.filter((item) => !item.disabled);\n  const [internal, setInternal] = React.useState(defaultValue ?? enabled[0]?.value);\n  const selected = value ?? internal;\n  // The tab reached with Tab: the selected one, or the first enabled tab as a fallback.\n  const focusable = enabled.some((item) => item.value === selected) ? selected : enabled[0]?.value;\n  const hasPanels = items.some((item) => item.content !== undefined);\n  const isClosable = (item: ScrollableTabItem) => !item.disabled && (item.closable ?? closable);\n  const indexOf = (v: string) => items.findIndex((item) => item.value === v);\n\n  const select = (next: string) => {\n    if (next === selected) return;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  /* ------------------------------ scroll state ------------------------------ */\n\n  const updateEdges = React.useCallback(() => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    const max = el.scrollWidth - el.clientWidth;\n    const start = el.scrollLeft > 1;\n    const end = el.scrollLeft < max - 1;\n    setEdges((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));\n  }, []);\n\n  // Track overflow on scroll and whenever the strip or its content changes size (fires once on observe).\n  React.useEffect(() => {\n    const el = scrollerRef.current;\n    const list = listRef.current;\n    if (!el || !list) return;\n    el.addEventListener(\"scroll\", updateEdges, { passive: true });\n    const ro = new ResizeObserver(updateEdges);\n    ro.observe(el);\n    ro.observe(list);\n    return () => {\n      el.removeEventListener(\"scroll\", updateEdges);\n      ro.disconnect();\n    };\n  }, [updateEdges]);\n\n  // Vertical wheel → horizontal scroll, but only while the strip can still move in that direction,\n  // so the page keeps scrolling normally once an end is reached.\n  React.useEffect(() => {\n    const el = scrollerRef.current;\n    if (!el || !wheelScroll) return;\n    const onWheel = (event: WheelEvent) => {\n      if (event.ctrlKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;\n      const max = el.scrollWidth - el.clientWidth;\n      if (max <= 0) return;\n      const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;\n      if ((delta < 0 && el.scrollLeft <= 0) || (delta > 0 && el.scrollLeft >= max - 1)) return;\n      event.preventDefault();\n      el.scrollLeft += delta;\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => el.removeEventListener(\"wheel\", onWheel);\n  }, [wheelScroll]);\n\n  /** Scrolls the strip (never the page) so the tab sits inside the unfaded area. */\n  const revealTab = React.useCallback(\n    (tab: HTMLElement, instant: boolean) => {\n      const el = scrollerRef.current;\n      if (!el) return;\n      const strip = el.getBoundingClientRect();\n      const rect = tab.getBoundingClientRect();\n      const pad = Math.min(FADE, strip.width / 4);\n      let delta = 0;\n      if (rect.left < strip.left + pad) delta = rect.left - (strip.left + pad);\n      else if (rect.right > strip.right - pad) delta = rect.right - (strip.right - pad);\n      if (Math.abs(delta) < 1) return;\n      el.scrollTo({ left: el.scrollLeft + delta, behavior: instant || reduceMotion ? \"auto\" : \"smooth\" });\n    },\n    [reduceMotion],\n  );\n\n  // Keep the selected tab in view (instantly on mount, smoothly afterwards).\n  React.useEffect(() => {\n    const tab = selected ? tabRefs.current.get(selected) : undefined;\n    if (tab) revealTab(tab, !didMountRef.current);\n    didMountRef.current = true;\n  }, [selected, revealTab]);\n\n  // After a close, move focus to the neighbour once the parent has removed the tab.\n  React.useEffect(() => {\n    const pending = pendingFocusRef.current;\n    if (!pending) return;\n    pendingFocusRef.current = null;\n    tabRefs.current.get(pending)?.focus({ preventScroll: true });\n  }, [items]);\n\n  const scrollByPage = (direction: 1 | -1) => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    el.scrollBy({ left: direction * el.clientWidth * 0.75, behavior: reduceMotion ? \"auto\" : \"smooth\" });\n  };\n\n  /* -------------------------------- closing -------------------------------- */\n\n  const close = (item: ScrollableTabItem) => {\n    if (!isClosable(item)) return;\n    const position = enabled.findIndex((entry) => entry.value === item.value);\n    const neighbour = enabled[position + 1] ?? enabled[position - 1];\n    const wasSelected = item.value === selected;\n    if (wasSelected && neighbour) select(neighbour.value);\n    const focusedInside = listRef.current?.contains(document.activeElement) ?? false;\n    if (neighbour && focusedInside) pendingFocusRef.current = neighbour.value;\n    setAnnouncement(`Closed ${textOf(item)}.${wasSelected && neighbour ? ` ${textOf(neighbour)} selected.` : \"\"}`);\n    onClose?.(item.value);\n  };\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const current = (event.target as HTMLElement).closest<HTMLElement>(\"[role=tab]\")?.dataset.value;\n    if (!current || !enabled.length) return;\n    const item = items[indexOf(current)];\n\n    if ((event.key === \"Delete\" || event.key === \"Backspace\") && item && isClosable(item)) {\n      event.preventDefault();\n      close(item);\n      return;\n    }\n    if (activation === \"manual\" && (event.key === \"Enter\" || event.key === \" \")) {\n      event.preventDefault();\n      select(current);\n      return;\n    }\n    if (![\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) return;\n    event.preventDefault();\n    const index = enabled.findIndex((entry) => entry.value === current);\n    const last = enabled.length - 1;\n    const nextIndex =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? last\n          : event.key === \"ArrowRight\"\n            ? (index + 1) % enabled.length\n            : (index - 1 + enabled.length) % enabled.length;\n    const next = enabled[nextIndex];\n    const tab = tabRefs.current.get(next.value);\n    tab?.focus({ preventScroll: true });\n    if (tab) revealTab(tab, false);\n    if (activation === \"automatic\") select(next.value);\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const fadeStart = fadeEdges && edges.start ? FADE : 0;\n  const fadeEnd = fadeEdges && edges.end ? FADE : 0;\n  const mask =\n    fadeStart || fadeEnd\n      ? `linear-gradient(to right, transparent 0, #000 ${fadeStart}px, #000 calc(100% - ${fadeEnd}px), transparent 100%)`\n      : undefined;\n  const underline = variant === \"underline\";\n  const hintId = `${id}-close-hint`;\n\n  return (\n    <div className={`w-full min-w-0 ${className}`} style={{ [\"--st-accent\" as string]: accent, ...style }} {...props}>\n      <div className={`relative ${underline ? \"\" : \"rounded-xl border border-zinc-900/[0.07] bg-zinc-100 p-1 dark:border-white/[0.06] dark:bg-zinc-900\"}`}>\n        <motion.div\n          ref={scrollerRef}\n          layoutScroll\n          className=\"overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n          style={{ maskImage: mask, WebkitMaskImage: mask }}\n        >\n          <div\n            ref={listRef}\n            role=\"tablist\"\n            aria-label={label}\n            aria-orientation=\"horizontal\"\n            onKeyDown={handleKeyDown}\n            className={`relative flex w-max min-w-full items-stretch ${\n              underline ? \"gap-1 border-b border-zinc-900/10 dark:border-white/10\" : \"gap-1\"\n            }`}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {items.map((item) => {\n                const isSelected = item.value === selected;\n                const index = indexOf(item.value);\n                const canClose = isClosable(item);\n                return (\n                  <motion.button\n                    key={item.value}\n                    ref={(node: HTMLButtonElement | null) => {\n                      if (node) tabRefs.current.set(item.value, node);\n                      else tabRefs.current.delete(item.value);\n                    }}\n                    layout={reduceMotion ? false : \"position\"}\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.15 } }}\n                    transition={transition}\n                    type=\"button\"\n                    role=\"tab\"\n                    id={`${id}-tab-${index}`}\n                    data-value={item.value}\n                    aria-selected={isSelected}\n                    aria-controls={hasPanels ? `${id}-panel-${index}` : undefined}\n                    aria-describedby={canClose ? hintId : undefined}\n                    tabIndex={item.value === focusable ? 0 : -1}\n                    disabled={item.disabled}\n                    onClick={() => select(item.value)}\n                    onFocus={(event) => revealTab(event.currentTarget, false)}\n                    onAuxClick={(event) => {\n                      // Middle-click closes, like browser tabs.\n                      if (event.button === 1 && canClose) {\n                        event.preventDefault();\n                        close(item);\n                      }\n                    }}\n                    className={`group/tab relative inline-flex shrink-0 select-none items-center gap-2 whitespace-nowrap text-[13px] font-medium tracking-tight outline-hidden transition-colors duration-200 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--st-accent)] disabled:cursor-not-allowed disabled:opacity-40 ${\n                      underline\n                        ? \"h-10 rounded-md px-3 text-zinc-500 hover:text-zinc-900 aria-selected:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-selected:text-white\"\n                        : \"h-8 rounded-lg px-3 text-zinc-500 hover:text-zinc-900 aria-selected:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-selected:text-white\"\n                    } ${canClose ? \"pr-2\" : \"\"}`}\n                  >\n                    {isSelected && (\n                      <motion.span\n                        aria-hidden=\"true\"\n                        // useId keeps the layoutId unique, so several strips on one page never share an indicator.\n                        layoutId={`${id}-indicator`}\n                        transition={transition}\n                        className={\n                          underline\n                            ? \"absolute inset-x-2 -bottom-px h-[2px] rounded-full bg-[var(--st-accent)]\"\n                            : \"absolute inset-0 rounded-lg bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.05)] dark:bg-zinc-800 dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.06)]\"\n                        }\n                      />\n                    )}\n                    {item.icon && (\n                      <span aria-hidden=\"true\" className=\"relative inline-flex [&_svg]:size-3.5\">\n                        {item.icon}\n                      </span>\n                    )}\n                    <span className=\"relative\">{item.label}</span>\n                    {canClose && (\n                      // Pointer target only: a nested button inside role=\"tab\" is invalid, so keyboard users press Delete.\n                      <span\n                        aria-hidden=\"true\"\n                        title={`Close ${textOf(item)}`}\n                        onClick={(event) => {\n                          event.stopPropagation();\n                          close(item);\n                        }}\n                        className=\"relative -my-1 inline-flex size-5 items-center justify-center rounded-md text-zinc-400 opacity-60 transition-[opacity,background-color,color] hover:bg-zinc-900/[0.07] hover:text-zinc-900 hover:opacity-100 group-hover/tab:opacity-100 group-aria-selected/tab:opacity-100 dark:text-zinc-500 dark:hover:bg-white/10 dark:hover:text-zinc-100\"\n                      >\n                        <svg viewBox=\"0 0 12 12\" className=\"size-2.5\" fill=\"none\">\n                          <path d=\"M3 3l6 6M9 3l-6 6\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" />\n                        </svg>\n                      </span>\n                    )}\n                  </motion.button>\n                );\n              })}\n            </AnimatePresence>\n          </div>\n        </motion.div>\n\n        {showScrollButtons && (\n          <>\n            <ScrollButton direction={-1} visible={edges.start} onClick={() => scrollByPage(-1)} inset={!underline} />\n            <ScrollButton direction={1} visible={edges.end} onClick={() => scrollByPage(1)} inset={!underline} />\n          </>\n        )}\n      </div>\n\n      <span id={hintId} className=\"sr-only\">\n        Press Delete to close this tab.\n      </span>\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n\n      {hasPanels &&\n        items.map((item, index) => {\n          const isSelected = item.value === selected;\n          return (\n            <div\n              key={item.value}\n              role=\"tabpanel\"\n              id={`${id}-panel-${index}`}\n              aria-labelledby={`${id}-tab-${index}`}\n              hidden={!isSelected}\n              tabIndex={0}\n              className=\"mt-4 rounded-xl outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--st-accent)]\"\n            >\n              {isSelected && (\n                <motion.div\n                  initial={reduceMotion ? false : { opacity: 0, y: 4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}\n                >\n                  {item.content}\n                </motion.div>\n              )}\n            </div>\n          );\n        })}\n    </div>\n  );\n}\n\nfunction ScrollButton({\n  direction,\n  visible,\n  onClick,\n  inset,\n}: {\n  direction: 1 | -1;\n  visible: boolean;\n  onClick: () => void;\n  inset: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      // Pointer convenience: keyboard users move with the arrow keys, which also scroll the strip.\n      tabIndex={-1}\n      aria-label={direction < 0 ? \"Scroll tabs left\" : \"Scroll tabs right\"}\n      aria-hidden={!visible}\n      onClick={onClick}\n      className={`absolute top-1/2 z-10 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-zinc-600 shadow-[0_1px_3px_rgba(0,0,0,0.08)] transition-[opacity,scale,color] duration-200 hover:text-zinc-950 active:scale-95 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:shadow-[0_1px_3px_rgba(0,0,0,0.5)] dark:hover:text-white ${\n        direction < 0 ? (inset ? \"left-1.5\" : \"left-0\") : inset ? \"right-1.5\" : \"right-0\"\n      } ${visible ? \"opacity-100\" : \"pointer-events-none scale-90 opacity-0\"}`}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" aria-hidden=\"true\">\n        <path\n          d={direction < 0 ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"}\n          stroke=\"currentColor\"\n          strokeWidth={1.6}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/scrollable-tabs"
}
