{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-tabs",
  "type": "registry:ui",
  "title": "Swipe Tabs",
  "description": "Mobile tabs with side-by-side panels on native scroll-snap and an indicator that tracks the swipe continuously.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/swipe-tabs.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface SwipeTab {\n  /** Unique id; used as the value. */\n  id: string;\n  /** Tab label. */\n  label: string;\n  /** Optional icon before the label. */\n  icon?: React.ReactNode;\n  /** Panel content. Each panel scrolls vertically on its own. */\n  content: React.ReactNode;\n}\n\nexport interface SwipeTabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  tabs: SwipeTab[];\n  /** Selected tab id (controlled). */\n  value?: string;\n  /** Initially selected tab id (uncontrolled). Defaults to the first tab. */\n  defaultValue?: string;\n  /** Called when a tab is chosen or a swipe settles on a new panel. */\n  onValueChange?: (id: string) => void;\n  /** Accessible name of the tab list. */\n  label?: string;\n  /** Indicator style. */\n  indicator?: \"line\" | \"pill\";\n  /** The indicator stretches mid-swipe and contracts as it lands. */\n  stretch?: boolean;\n  /** Allow swiping between panels. When false, only the tabs switch panels. */\n  swipeable?: boolean;\n  /** Indicator and focus colour. */\n  accent?: 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 clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\n/**\n * Mobile-style tabs whose panels sit side by side in a native scroll-snap track. The indicator follows\n * the swipe continuously: scroll position is written to CSS custom properties on every frame (no React\n * renders), and the selection only changes once the scroll settles. Tapping a tab scrolls to its panel;\n * inactive panels are inert once settled.\n */\nexport function SwipeTabs({\n  tabs,\n  value,\n  defaultValue,\n  onValueChange,\n  label = \"Sections\",\n  indicator = \"line\",\n  stretch = true,\n  swipeable = true,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: SwipeTabsProps) {\n  const id = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const mountedRef = React.useRef(false);\n\n  const [internal, setInternal] = React.useState(defaultValue ?? tabs[0]?.id);\n  const selected = value ?? internal;\n  const index = Math.max(0, tabs.findIndex((tab) => tab.id === selected));\n  const count = tabs.length;\n\n  const select = (next: string) => {\n    if (next === selected) return;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  /* ------------------------- scroll-linked indicator ------------------------- */\n\n  // Writes the fractional position to CSS variables. React never sets --sw-p itself (it sets the\n  // fallback --sw-i), so a re-render mid-swipe cannot snap the indicator back.\n  const writeProgress = React.useCallback(\n    (p: number) => {\n      const root = rootRef.current;\n      if (!root) return;\n      const frac = p - Math.floor(p);\n      root.style.setProperty(\"--sw-p\", p.toFixed(4));\n      root.style.setProperty(\"--sw-s\", stretch ? (Math.sin(frac * Math.PI) * 0.6).toFixed(4) : \"0\");\n      tabRefs.current.forEach((tab, i) => tab?.style.setProperty(\"--sw-near\", clamp(1 - Math.abs(p - i), 0, 1).toFixed(3)));\n    },\n    [stretch],\n  );\n\n  const settle = React.useEffectEvent((settledIndex: number) => {\n    const tab = tabs[settledIndex];\n    if (tab) select(tab.id);\n  });\n\n  React.useEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    let frame = 0;\n    let timer = 0;\n    const position = () => (scroller.clientWidth ? scroller.scrollLeft / scroller.clientWidth : 0);\n    const finish = () => {\n      window.clearTimeout(timer);\n      timer = 0;\n      settle(clamp(Math.round(position()), 0, count - 1));\n    };\n    const onScroll = () => {\n      if (!frame)\n        frame = requestAnimationFrame(() => {\n          frame = 0;\n          writeProgress(clamp(position(), 0, count - 1));\n        });\n      // Fallback for browsers without \"scrollend\": settle shortly after the last scroll event.\n      window.clearTimeout(timer);\n      timer = window.setTimeout(finish, 140);\n    };\n    writeProgress(clamp(position(), 0, count - 1));\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n    scroller.addEventListener(\"scrollend\", finish);\n    return () => {\n      scroller.removeEventListener(\"scroll\", onScroll);\n      scroller.removeEventListener(\"scrollend\", finish);\n      cancelAnimationFrame(frame);\n      window.clearTimeout(timer);\n    };\n  }, [count, writeProgress]);\n\n  // Bring the selected panel into view when the value changes (tab tap, keyboard, controlled update).\n  const syncScroll = React.useEffectEvent((instant: boolean) => {\n    const scroller = scrollerRef.current;\n    if (!scroller || !scroller.clientWidth) return;\n    const target = index * scroller.clientWidth;\n    if (Math.abs(scroller.scrollLeft - target) < 1) return;\n    scroller.scrollTo({ left: target, behavior: instant || reduceMotion ? \"auto\" : \"smooth\" });\n  });\n\n  React.useEffect(() => {\n    syncScroll(!mountedRef.current);\n    mountedRef.current = true;\n  }, [index]);\n\n  // Keep the selected panel aligned when the track is resized.\n  React.useEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    const ro = new ResizeObserver(() => syncScroll(true));\n    ro.observe(scroller);\n    return () => ro.disconnect();\n  }, []);\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const keys: Record<string, number> = {\n      ArrowRight: (index + 1) % count,\n      ArrowLeft: (index - 1 + count) % count,\n      Home: 0,\n      End: count - 1,\n    };\n    const next = keys[event.key];\n    if (next === undefined) return;\n    event.preventDefault();\n    tabRefs.current[next]?.focus();\n    select(tabs[next].id);\n  };\n\n  /* ---------------------------------- render --------------------------------- */\n\n  const pill = indicator === \"pill\";\n  const ring =\n    \"outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sw-accent)]\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={`flex min-h-0 flex-col [--sw-off:var(--color-zinc-500)] [--sw-on:var(--color-zinc-950)] dark:[--sw-off:var(--color-zinc-400)] dark:[--sw-on:var(--color-white)] ${className}`}\n      style={{ [\"--sw-accent\" as string]: accent, [\"--sw-i\" as string]: index, ...style }}\n      {...props}\n    >\n      <div className={pill ? \"shrink-0 px-3 pb-2 pt-1\" : \"shrink-0 border-b border-zinc-900/10 dark:border-white/10\"}>\n        <div className={pill ? \"rounded-full bg-zinc-900/[0.06] p-1 dark:bg-white/[0.07]\" : \"\"}>\n          <div\n            role=\"tablist\"\n            aria-label={label}\n            onKeyDown={handleKeyDown}\n            className=\"relative grid\"\n            style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}\n          >\n            <span\n              aria-hidden=\"true\"\n              className={`pointer-events-none absolute left-0 ${pill ? \"inset-y-0\" : \"bottom-0 h-[2px]\"}`}\n              style={{ width: `${(100 / Math.max(count, 1)).toFixed(4)}%`, translate: \"calc(var(--sw-p, var(--sw-i)) * 100%) 0\" }}\n            >\n              <span\n                className={\n                  pill\n                    ? \"absolute inset-0 rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)] dark:bg-zinc-700 dark:shadow-[0_1px_2px_rgba(0,0,0,0.5)]\"\n                    : \"absolute inset-y-0 left-1/2 w-[46%] -translate-x-1/2 rounded-full bg-[var(--sw-accent)]\"\n                }\n                style={{ scale: \"calc(1 + var(--sw-s, 0)) 1\" }}\n              />\n            </span>\n            {tabs.map((tab, i) => {\n              const isSelected = i === index;\n              return (\n                <button\n                  key={tab.id}\n                  ref={(el) => {\n                    tabRefs.current[i] = el;\n                  }}\n                  type=\"button\"\n                  role=\"tab\"\n                  id={`${id}-tab-${i}`}\n                  aria-selected={isSelected}\n                  aria-controls={`${id}-panel-${i}`}\n                  tabIndex={isSelected ? 0 : -1}\n                  onClick={() => select(tab.id)}\n                  className={`relative flex min-w-0 items-center justify-center gap-1.5 text-[13px] font-medium tracking-tight ${\n                    pill ? \"h-8 rounded-full\" : \"h-11\"\n                  } ${ring}`}\n                  style={\n                    {\n                      \"--sw-sel\": isSelected ? 1 : 0,\n                      color: \"color-mix(in oklab, var(--sw-on) calc(var(--sw-near, var(--sw-sel)) * 100%), var(--sw-off))\",\n                    } as React.CSSProperties\n                  }\n                >\n                  {tab.icon && (\n                    <span aria-hidden=\"true\" className=\"inline-flex shrink-0 [&_svg]:size-4\">\n                      {tab.icon}\n                    </span>\n                  )}\n                  <span className=\"truncate\">{tab.label}</span>\n                </button>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div\n        ref={scrollerRef}\n        className={`flex min-h-0 flex-1 snap-x snap-mandatory overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${\n          swipeable ? \"overflow-x-auto\" : \"overflow-x-hidden\"\n        } overflow-y-hidden`}\n      >\n        {tabs.map((tab, i) => {\n          const isSelected = i === index;\n          return (\n            <div\n              key={tab.id}\n              role=\"tabpanel\"\n              id={`${id}-panel-${i}`}\n              aria-labelledby={`${id}-tab-${i}`}\n              // Inactive panels stay rendered (so they can be swiped into view) but are inert.\n              inert={!isSelected}\n              tabIndex={0}\n              className={`h-full w-full shrink-0 snap-start snap-always overflow-y-auto overscroll-y-contain ${ring}`}\n            >\n              {tab.content}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/swipe-tabs"
}
