{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs-carriage",
  "title": "Tabs Carriage",
  "description": "Tabs whose underline is a typewriter carriage on a rail: forward moves glide on a spring and stretch with speed, backward moves snap home faster with a ding-bounce, and the panel line-feeds in from the travel direction.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/tabs-carriage/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\n\nexport type CarriageTab = {\n  id: string;\n  label: string;\n  content: ReactNode;\n};\n\n// Typewriter-carriage tab indicator: the underline is a carriage on a rail.\n// Forward moves glide on a spring with velocity-proportional stretch; a\n// backward move is a carriage RETURN — faster, harder overshoot, and a small\n// vertical ding-bounce on arrival. Direct-DOM rAF, zero React state on the\n// hot path.\nexport function CarriageReturn({\n  tabs,\n  defaultTab,\n  className = \"\",\n}: {\n  /** the tabs, in order */\n  tabs: CarriageTab[];\n  /** id of the initially selected tab (defaults to the first) */\n  defaultTab?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const baseId = useId();\n  const [active, setActive] = useState(\n    () => defaultTab ?? tabs[0]?.id ?? \"\"\n  );\n  const listRef = useRef<HTMLDivElement>(null);\n  const carriageRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const tabRefs = useRef(new Map<string, HTMLButtonElement>());\n  // spring state lives in a ref — the loop never touches React\n  const sim = useRef({\n    x: 0,\n    w: 0,\n    y: 0,\n    vx: 0,\n    vw: 0,\n    vy: 0,\n    tx: 0,\n    tw: 0,\n    raf: 0,\n    settledOnce: false,\n    reduced: false,\n  });\n\n  const activeIndex = Math.max(\n    0,\n    tabs.findIndex((t) => t.id === active)\n  );\n\n  // retarget the springs whenever the active tab (or layout) changes\n  useEffect(() => {\n    const list = listRef.current;\n    const carriage = carriageRef.current;\n    if (!list || !carriage) return;\n    const s = sim.current;\n    s.reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const measure = () => {\n      const el = tabRefs.current.get(active);\n      if (!el) return;\n      const wasLeft = s.tx;\n      s.tx = el.offsetLeft;\n      s.tw = el.offsetWidth;\n      if (!s.settledOnce || s.reduced) {\n        // first paint / reduced motion: place, don't animate\n        s.x = s.tx;\n        s.w = s.tw;\n        s.vx = s.vw = s.vy = 0;\n        s.y = 0;\n        carriage.style.transform = `translateX(${s.x}px)`;\n        carriage.style.width = `${s.w}px`;\n        s.settledOnce = true;\n        return;\n      }\n      // backward travel = carriage return: kick harder, add the ding dip\n      if (s.tx < wasLeft - 1) {\n        s.vx -= (wasLeft - s.tx) * 4;\n        s.vy = 60; // downward ding, springs back\n      }\n      wake();\n    };\n\n    const K = 380; // s^-2\n    const ZETA = 0.72;\n    const step = (dt: number) => {\n      const c = 2 * ZETA * Math.sqrt(K);\n      s.vx += (K * (s.tx - s.x) - c * s.vx) * dt;\n      s.vw += (K * (s.tw - s.w) - c * s.vw) * dt;\n      s.vy += (900 * (0 - s.y) - 2 * 0.5 * Math.sqrt(900) * s.vy) * dt;\n      s.x += s.vx * dt;\n      s.w += s.vw * dt;\n      s.y += s.vy * dt;\n    };\n\n    let last = 0;\n    const loop = (t: number) => {\n      const dt = Math.min((t - (last || t)) / 1000, 1 / 30);\n      last = t;\n      step(dt);\n      const settled =\n        Math.abs(s.tx - s.x) < 0.3 &&\n        Math.abs(s.tw - s.w) < 0.3 &&\n        Math.abs(s.vx) < 4 &&\n        Math.abs(s.y) < 0.3 &&\n        Math.abs(s.vy) < 4;\n      if (settled) {\n        s.x = s.tx;\n        s.w = s.tw;\n        s.y = 0;\n        s.vx = s.vw = s.vy = 0;\n      }\n      // velocity-proportional stretch; squash falls out of the overshoot\n      const stretch = 1 + Math.min(Math.abs(s.vx) / 2600, 0.45);\n      carriage.style.transform = `translateX(${s.x}px) translateY(${s.y * 0.06}px) scaleX(${stretch})`;\n      carriage.style.width = `${s.w}px`;\n      if (settled) {\n        carriage.style.transform = `translateX(${s.x}px)`;\n        s.raf = 0;\n        return;\n      }\n      s.raf = requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (!s.raf) {\n        last = 0;\n        s.raf = requestAnimationFrame(loop);\n      }\n    };\n\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(list);\n    return () => {\n      ro.disconnect();\n      if (s.raf) cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, [active]);\n\n  // panel line-feed: new content enters from the travel direction\n  const prevIndex = useRef(activeIndex);\n  useEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return;\n    if (sim.current.reduced) return;\n    const dir = activeIndex >= prevIndex.current ? 1 : -1;\n    prevIndex.current = activeIndex;\n    panel.animate(\n      [\n        { opacity: 0, transform: `translateX(${dir * 12}px)` },\n        { opacity: 1, transform: \"translateX(0)\" },\n      ],\n      { duration: 220, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }\n    );\n  }, [activeIndex]);\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    let next = -1;\n    if (e.key === \"ArrowRight\") next = (activeIndex + 1) % tabs.length;\n    else if (e.key === \"ArrowLeft\")\n      next = (activeIndex - 1 + tabs.length) % tabs.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = tabs.length - 1;\n    if (next === -1) return;\n    e.preventDefault();\n    const id = tabs[next].id;\n    setActive(id);\n    tabRefs.current.get(id)?.focus();\n  };\n\n  const activeTab = tabs[activeIndex];\n\n  return (\n    <div className={className}>\n      <div className=\"relative border-b border-border\">\n        <div\n          ref={listRef}\n          role=\"tablist\"\n          aria-label=\"Sections\"\n          className=\"flex\"\n          onKeyDown={onKeyDown}\n        >\n          {tabs.map((tab) => {\n            const selected = tab.id === active;\n            return (\n              <button\n                key={tab.id}\n                ref={(el) => {\n                  if (el) tabRefs.current.set(tab.id, el);\n                  else tabRefs.current.delete(tab.id);\n                }}\n                role=\"tab\"\n                id={`${baseId}-tab-${tab.id}`}\n                aria-selected={selected}\n                aria-controls={`${baseId}-panel-${tab.id}`}\n                tabIndex={selected ? 0 : -1}\n                onClick={() => setActive(tab.id)}\n                className={`relative rounded-sm px-4 py-2.5 text-sm transition-colors duration-150 hover:bg-foreground/[0.06] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent ${\n                  selected\n                    ? \"font-medium text-foreground\"\n                    : \"text-ns-muted hover:text-foreground\"\n                }`}\n              >\n                {tab.label}\n              </button>\n            );\n          })}\n        </div>\n        {/* the carriage: rides the rail (the border-b) */}\n        <div\n          ref={carriageRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -bottom-px left-0 h-0.5 bg-foreground\"\n          style={{ transformOrigin: \"center\" }}\n        />\n      </div>\n      {activeTab && (\n        <div\n          ref={panelRef}\n          role=\"tabpanel\"\n          id={`${baseId}-panel-${activeTab.id}`}\n          aria-labelledby={`${baseId}-tab-${activeTab.id}`}\n          tabIndex={0}\n          className=\"rounded-sm px-1 py-4 text-sm leading-relaxed text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n        >\n          {activeTab.content}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/tabs-carriage.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "tabs",
      "navigation",
      "spring",
      "physics",
      "indicator",
      "keyboard"
    ],
    "instruction": "Build a tab component whose active-tab underline behaves like a typewriter carriage riding the tablist's bottom border rail. The indicator's x-position and width are each driven by an underdamped spring (k = 380 s^-2, zeta = 0.72) toward the active tab's offsetLeft/offsetWidth, stepped in a single direct-DOM rAF loop with zero React state on the hot path; the loop sleeps when position, width, and velocity are all under epsilon and wakes on retarget. While traveling, the carriage stretches horizontally in proportion to |velocity| (scaleX up to 1.45, transform-origin center) so speed is legible, and the squash on arrival falls out of the spring's overshoot rather than being keyframed. Direction matters: a move to a LOWER index is a carriage RETURN — the spring gets an extra leftward velocity kick proportional to the travel distance plus a small downward y impulse on its own stiffer spring (k = 900, zeta = 0.5), producing the typewriter ding dip-and-recover on arrival; rightward moves stay calm. The tab panel line-feeds on every change: content enters with a 220 ms translateX from the travel direction using an ease-out-expo Web Animations call. First paint and any ResizeObserver-detected layout change seat the carriage instantly with velocities zeroed — never animate a resize. Full tablist semantics: role=tablist/tab/tabpanel, aria-selected, aria-controls/labelledby via useId, roving tabindex with automatic activation on ArrowLeft/ArrowRight (wrapping) and Home/End, the panel itself focusable, selected tab in foreground weight-medium against muted siblings, token-relative accent focus-visible rings only. Under prefers-reduced-motion the carriage repositions instantly with no stretch, kick, or panel slide. Colors come only from theme tokens (the carriage is bg-foreground on the border rail)."
  },
  "type": "registry:ui"
}