{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "title": "Tabs",
  "description": "Words with their counts raised. A line inches to the one in view and the count of what's showing sinks into the rule; or the chosen word takes its width from the others; or the rule beside a stack of words opens onto the panel.",
  "dependencies": [
    "@radix-ui/react-tabs"
  ],
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/tabs.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\nimport { roll } from \"@/lib/0nlytype/roll\"\n\ntype TabsProps = React.ComponentProps<typeof TabsPrimitive.Root> & {\n  /**\n   * line: a line slides under the word in view, leading edge first.\n   * rubato: no line; the chosen word takes width and weight from the others, and the bar keeps its length.\n   * open: the words stacked in ruled cells beside the panel; the rule between them opens beside the chosen one.\n   * Vertical by default, so Up and Down move.\n   */\n  variant?: \"line\" | \"rubato\" | \"open\"\n}\n\nfunction Tabs({ className, variant = \"line\", orientation, dir, ref, ...props }: TabsProps) {\n  // Radix writes dir=\"ltr\" on the root unless told otherwise, which would turn a right-to-left page's\n  // tabs and arrow keys around. Without a dir, the tabs take the direction of the page around them,\n  // read as they mount, before the first paint (as the menus and the toggle group do).\n  const [around, setAround] = React.useState<\"rtl\">()\n  const composedRef = useComposedRefs(React.useCallback((node: HTMLDivElement | null) => {\n    const up = node?.parentElement\n    if (!dir && up && getComputedStyle(up).direction === \"rtl\") setAround(\"rtl\")\n  }, [dir]), ref)\n  return (\n    <TabsPrimitive.Root\n      ref={composedRef}\n      dir={dir ?? around}\n      data-slot=\"tabs\"\n      data-variant={variant}\n      orientation={orientation ?? (variant === \"open\" ? \"vertical\" : \"horizontal\")}\n      className={cn(\"ot-tabs-root\", className)}\n      {...props}\n    />\n  )\n}\n\n/**\n * Sets where the line goes: --x (left), --r (right inset) and --y (lift to the\n * last row when the words wrap), measured from the active tab, plus --t and --b\n * (its top and bottom insets) for the open variant's break in the rule. data-dir\n * says which way it moved (left, right, up, down) so the leading edge can go\n * first. Physical measurements, so right-to-left needs nothing more.\n */\nfunction useFollow() {\n  const list = React.useRef<HTMLDivElement | null>(null)\n  React.useLayoutEffect(() => {\n    const el = list.current\n    if (!el) return\n    let ready = false\n    const follow = () => {\n      const on = el.querySelector<HTMLElement>('[role=\"tab\"][data-state=\"active\"]')\n      if (!on) return\n      const b = el.getBoundingClientRect()\n      const r = on.getBoundingClientRect()\n      const lastRow = Math.max(...[...el.querySelectorAll('[role=\"tab\"]')].map((t) => t.getBoundingClientRect().bottom))\n      const k = b.width / el.offsetWidth || 1 // on-screen px per CSS px: a scaled or zoomed ancestor would throw the line off\n      const x = (r.left - b.left) / k\n      const t = (r.top - b.top) / k\n      const was = parseFloat(el.style.getPropertyValue(\"--x\")) || 0\n      const wasT = parseFloat(el.style.getPropertyValue(\"--t\")) || 0\n      el.dataset.dir = Math.abs(x - was) > 0.5 ? (x > was ? \"right\" : \"left\") : t >= wasT ? \"down\" : \"up\"\n      el.style.setProperty(\"--x\", `${x}px`)\n      el.style.setProperty(\"--t\", `${t}px`)\n      el.style.setProperty(\"--b\", `${(b.bottom - r.bottom) / k}px`)\n      el.style.setProperty(\"--r\", `${(b.right - r.right) / k}px`)\n      el.style.setProperty(\"--y\", `${(r.bottom - lastRow) / k}px`)\n    }\n    follow()\n    // Nothing glides until the person has chosen: the first place is just where it starts.\n    const chose = new MutationObserver(() => {\n      if (!ready) el.setAttribute(\"data-ready\", \"\")\n      ready = true\n      follow()\n    })\n    chose.observe(el, { attributes: true, attributeFilter: [\"data-state\"], subtree: true })\n    const resize = new ResizeObserver(follow)\n    resize.observe(el)\n    el.querySelectorAll('[role=\"tab\"]').forEach((t) => resize.observe(t))\n    document.fonts?.ready.then(follow)\n    return () => {\n      chose.disconnect()\n      resize.disconnect()\n    }\n  }, [])\n  return list\n}\n\n/** Words with a hairline beneath; a line slides to the one in view, leading edge first. */\nfunction TabsList({ className, children, ref, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {\n  const follow = useFollow()\n  const composedRef = useComposedRefs(follow, ref)\n  return (\n    <TabsPrimitive.List\n      ref={composedRef}\n      data-slot=\"tabs-list\"\n      className={cn(\"ot-tabs\", className)}\n      {...props}\n    >\n      {children}\n      <span data-slot=\"tabs-line\" className=\"ot-tabs-line\" aria-hidden=\"true\" />\n    </TabsPrimitive.List>\n  )\n}\n\ntype TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger> & {\n  /** How many things are behind this tab, raised beside its name. */\n  count?: number | string\n}\n\nfunction TabsTrigger({ className, count, children, ...props }: TabsTriggerProps) {\n  return (\n    <TabsPrimitive.Trigger data-slot=\"tabs-trigger\" className={className} {...props}>\n      {children}\n      {count !== undefined ? <sup>{count}</sup> : null}\n    </TabsPrimitive.Trigger>\n  )\n}\n\n/** The count of what's showing, set huge and cropped by the rule it sinks into. Put it inside TabsList; it rolls when the number changes. */\nfunction TabsCount({ children, className, ...props }: Omit<React.ComponentProps<\"span\">, \"children\"> & { children: number | string }) {\n  const text = String(children)\n  const inner = React.useRef<HTMLSpanElement>(null)\n  const [shown, setShown] = React.useState(text)\n  React.useEffect(() => {\n    if (text === shown) return\n    const el = inner.current\n    if (!el) return setShown(text)\n    roll(el, () => setShown(text), \"100%\", +text > +shown ? 1 : -1)\n  }, [text, shown])\n  return (\n    <span data-slot=\"tabs-count\" aria-hidden=\"true\" className={cn(\"ot-tabs-count\", className)} {...props}>\n      <span ref={inner}>{shown}</span>\n    </span>\n  )\n}\n\nfunction TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return <TabsPrimitive.Content data-slot=\"tabs-content\" className={className} {...props} />\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent, TabsCount, type TabsProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/tabs.css",
      "content": "@layer components {\n  /* ── ot-tabs ── words; a line slides under the one in view.\n     Radix keeps the roving focus and the arrow keys; the line reads --x, --r, --y\n     from the active tab (see tabs.tsx). */\n  .ot-tabs {\n    position: relative; display: flex; flex-wrap: wrap; column-gap: var(--ot-space-6); row-gap: var(--ot-space-2);\n    padding-block-end: var(--ot-space-3);\n    background: linear-gradient(var(--ot-rule), var(--ot-rule)) no-repeat 0 100% / 100% var(--ot-hairline);\n  }\n  .ot-tabs > [role=\"tab\"] {\n    appearance: none; border: 0; background: none; margin: 0; padding: 0; cursor: pointer;\n    font-size: var(--ot-mp); line-height: var(--ot-mp-lh); color: var(--ot-pencil);\n    transition: color var(--ot-allegro);\n  }\n  .ot-tabs > [role=\"tab\"]:is(:hover, [data-force~=\"hover\"]) { color: var(--ot-graphite); }\n  .ot-tabs > [role=\"tab\"][data-state=\"active\"] { color: var(--ot-ink); }\n  .ot-tabs > [role=\"tab\"]:disabled { cursor: not-allowed; opacity: 0.5; }\n  .ot-tabs sup { font-size: 0.5em; line-height: 0; vertical-align: 0.95em; margin-inline-start: 0.2em; font-variant-numeric: tabular-nums; color: var(--ot-pencil); }\n  .ot-tabs-line {\n    position: absolute; left: var(--x, 0px); right: var(--r, 100%); bottom: 0; height: var(--ot-stroke);\n    background: var(--ot-ink); translate: 0 var(--y, 0px); pointer-events: none;\n    transition: left var(--ot-andante) var(--ot-breath), right var(--ot-andante) var(--ot-breath), translate var(--ot-moderato) var(--ot-breath);\n  }\n  .ot-tabs[data-dir=\"right\"] .ot-tabs-line { transition-delay: 110ms, 0s, 0s; }\n  .ot-tabs[data-dir=\"left\"] .ot-tabs-line { transition-delay: 0s, 110ms, 0s; }\n  .ot-tabs:not([data-ready]) .ot-tabs-line { transition: none; }\n  /* The count of what's showing, set huge and cropped by the rule it sinks into. */\n  .ot-tabs-count {\n    position: absolute; inset-inline-end: 0; inset-block-end: 0; block-size: 0.56em; overflow: clip; pointer-events: none;\n    font-size: clamp(5rem, 10vw, 9rem); font-weight: 100; font-stretch: 112%; line-height: 0.78; letter-spacing: -0.04em;\n    color: var(--ot-ink); font-variant-numeric: tabular-nums lining-nums;\n  }\n  .ot-tabs-count > span { display: block; }\n  @media (max-width: 700px) { .ot-tabs-count { display: none; } }\n  @media (max-width: 40rem) { .ot-tabs { column-gap: var(--ot-space-4); } } /* four words stay on one line at phone width, so the line has one row to sit under */\n\n  /* rubato: \"robbed time\". No line: the chosen word takes width and weight from the others (font-stretch 75% to\n     125%, weight 300 to 600), and the words are spread to both ends so the bar keeps its length while they give\n     and take. Pointing leans a word a little wider, in pencil. Voices without a width axis change weight only. */\n  .ot-tabs-root[data-variant=\"rubato\"] > .ot-tabs { justify-content: space-between; column-gap: var(--ot-space-4); max-inline-size: calc(var(--ot-mp) * 26); } /* a bar, not the page: the give and take stays in view */\n  .ot-tabs-root[data-variant=\"rubato\"] > .ot-tabs > [role=\"tab\"] {\n    font-stretch: 75%; font-weight: 300;\n    transition: color var(--ot-allegro), font-stretch var(--ot-andante) var(--ot-breath), font-weight var(--ot-andante) var(--ot-breath);\n  }\n  .ot-tabs-root[data-variant=\"rubato\"] > .ot-tabs > [role=\"tab\"]:is(:hover, [data-force~=\"hover\"]):not([data-state=\"active\"], :disabled) { font-stretch: 90%; font-weight: 350; }\n  .ot-tabs-root[data-variant=\"rubato\"] > .ot-tabs > [role=\"tab\"][data-state=\"active\"] { font-stretch: 125%; font-weight: 600; }\n  .ot-tabs-root[data-variant=\"rubato\"] > .ot-tabs > .ot-tabs-line { display: none; }\n\n  /* open: the words stacked large like a headline beside the panel, one hairline standing between the two\n     columns (\"Less is more.\"). The hairline breaks beside the chosen word, so the word opens onto its panel:\n     the choice is marked by a missing line. The break moves like the line, leading edge first. Vertical: Up and Down move. */\n  .ot-tabs-root[data-variant=\"open\"] { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: start; padding-block: var(--ot-space-4); }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs { flex-direction: column; flex-wrap: nowrap; gap: 0; padding: 0; background: none; }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs > [role=\"tab\"] {\n    text-align: start; padding-block: var(--ot-space-2); padding-inline-end: var(--ot-space-8);\n    font-size: var(--ot-mf); line-height: var(--ot-mf-lh); letter-spacing: var(--ot-mf-tr); font-weight: 300;\n    transition: color var(--ot-allegro), font-weight var(--ot-moderato) var(--ot-breath);\n  }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs > [role=\"tab\"][data-state=\"active\"] { font-weight: 450; }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs > .ot-tabs-line { display: none; }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs::before,\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs::after {\n    content: \"\"; position: absolute; inset-inline-end: 0; inline-size: var(--ot-hairline); background: var(--ot-rule-strong); pointer-events: none;\n    transition: block-size var(--ot-andante) var(--ot-breath);\n  }\n  /* The hairline overhangs the stack at both ends, so the first or last word breaks it too, not just ends it. */\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs::before { inset-block-start: calc(-1 * var(--ot-space-4)); block-size: calc(var(--t, 100%) + var(--ot-space-4)); }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs::after { inset-block-end: calc(-1 * var(--ot-space-4)); block-size: calc(var(--b, 0px) + var(--ot-space-4)); }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs[data-dir=\"down\"]::before { transition-delay: 110ms; }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs[data-dir=\"up\"]::after { transition-delay: 110ms; }\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs:not([data-ready])::before,\n  .ot-tabs-root[data-variant=\"open\"] > .ot-tabs:not([data-ready])::after { transition: none; }\n  .ot-tabs-root[data-variant=\"open\"] > [role=\"tabpanel\"] { grid-column: 2; grid-row: 1; min-inline-size: 0; padding-inline-start: var(--ot-space-6); padding-block-start: var(--ot-space-2); }\n  @media (max-width: 40rem) {\n    .ot-tabs-root[data-variant=\"open\"] > .ot-tabs > [role=\"tab\"] { font-size: var(--ot-mp); line-height: var(--ot-mp-lh); letter-spacing: var(--ot-mp-tr); padding-inline-end: var(--ot-space-5); }\n    .ot-tabs-root[data-variant=\"open\"] > [role=\"tabpanel\"] { padding-inline-start: var(--ot-space-4); }\n  }\n  @media (forced-colors: active) {\n    .ot-tabs-line, .ot-tabs-root[data-variant=\"open\"] > .ot-tabs::before, .ot-tabs-root[data-variant=\"open\"] > .ot-tabs::after { forced-color-adjust: none; background: CanvasText; }\n    .ot-tabs > [role=\"tab\"][data-state=\"active\"] { color: Highlight; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/tabs.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/tabs.css\"": {}
  },
  "meta": {
    "movement": "VIII",
    "underneath": "radix",
    "contract": "ot-tabs"
  },
  "type": "registry:ui"
}
