{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "label-roll",
  "title": "Label Roll",
  "description": "Letter-staggered label swap on hover/focus for links and secondary CTAs.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@atroui/motion",
    "@atroui/utils"
  ],
  "files": [
    {
      "path": "registry/default/ui/label-roll.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype LabelRollProps = {\n  children: React.ReactNode\n  className?: string\n  /** Second line — defaults to the same children (hover roll). */\n  secondary?: React.ReactNode\n  /**\n   * When true, the roll surface is focusable (standalone control).\n   * Keep false when wrapping with `<a>` / `<button>` so you don't nest tab stops.\n   */\n  interactive?: boolean\n  /**\n   * Stagger between letters (ms). Only applies when both labels are strings.\n   * Default 18 — readable cascade without feeling sluggish.\n   */\n  staggerMs?: number\n  /**\n   * Roll duration (ms). Default 320 — easeOutExpo settle, not a snap.\n   */\n  durationMs?: number\n}\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\" // easeOutExpo\n\nfunction toChars(value: string) {\n  return Array.from(value)\n}\n\n/**\n * Full literals so Tailwind JIT can see every variant.\n * Triggers: own `group/roll`, parent `group` (hover + focus), focus-within when interactive.\n */\nconst delayActive =\n  \"group-hover:[transition-delay:calc(var(--i)*var(--stagger))] group-focus:[transition-delay:calc(var(--i)*var(--stagger))] group-hover/roll:[transition-delay:calc(var(--i)*var(--stagger))] group-focus-within/roll:[transition-delay:calc(var(--i)*var(--stagger))]\"\n\nconst primaryExit =\n  \"group-hover:-translate-y-full group-hover:opacity-0 group-focus:-translate-y-full group-focus:opacity-0 group-hover/roll:-translate-y-full group-hover/roll:opacity-0 group-focus-within/roll:-translate-y-full group-focus-within/roll:opacity-0\"\n\nconst secondaryEnter =\n  \"group-hover:translate-y-0 group-hover:opacity-100 group-focus:translate-y-0 group-focus:opacity-100 group-hover/roll:translate-y-0 group-hover/roll:opacity-100 group-focus-within/roll:translate-y-0 group-focus-within/roll:opacity-100\"\n\n/**\n * Letter columns: primary exits up, secondary enters from below.\n * Unequal lengths share one width via an invisible sizer.\n * Hover delay staggers in; leave resets delay → chars settle together.\n */\nfunction LabelRollChars({\n  primary,\n  secondary,\n  className,\n  interactive,\n  staggerMs,\n  durationMs,\n}: {\n  primary: string\n  secondary: string\n  className?: string\n  interactive: boolean\n  staggerMs: number\n  durationMs: number\n}) {\n  const primaryChars = toChars(primary)\n  const secondaryChars = toChars(secondary)\n  const sizer = primary.length >= secondary.length ? primary : secondary\n\n  const glyph = cn(\n    \"block leading-[1.15] will-change-transform\",\n    \"transition-[transform,opacity] ease-[var(--ease)] [transition-duration:var(--dur)] [transition-delay:0ms]\",\n    \"motion-reduce:transition-none\",\n    delayActive\n  )\n\n  return (\n    <span\n      className={cn(\n        \"group/roll relative inline-grid align-bottom outline-none\",\n        className\n      )}\n      tabIndex={interactive ? 0 : undefined}\n      aria-label={primary}\n      style={\n        {\n          \"--stagger\": `${staggerMs}ms`,\n          \"--dur\": `${durationMs}ms`,\n          \"--ease\": EASE,\n        } as React.CSSProperties\n      }\n    >\n      <span\n        className=\"invisible col-start-1 row-start-1 whitespace-pre leading-[1.15]\"\n        aria-hidden\n      >\n        {sizer}\n      </span>\n\n      <span\n        className=\"col-start-1 row-start-1 inline-flex h-[1.15em] overflow-hidden whitespace-pre motion-reduce:relative\"\n        aria-hidden\n      >\n        {primaryChars.map((char, i) => (\n          <span key={`a-${i}`} className=\"inline-block overflow-hidden\">\n            <span\n              className={cn(\n                glyph,\n                \"translate-y-0 opacity-100 motion-reduce:!translate-y-0 motion-reduce:!opacity-100\",\n                primaryExit\n              )}\n              style={{ \"--i\": i } as React.CSSProperties}\n            >\n              {char === \" \" ? \"\\u00A0\" : char}\n            </span>\n          </span>\n        ))}\n      </span>\n\n      <span\n        className=\"col-start-1 row-start-1 inline-flex h-[1.15em] overflow-hidden whitespace-pre motion-reduce:hidden\"\n        aria-hidden\n      >\n        {secondaryChars.map((char, i) => (\n          <span key={`b-${i}`} className=\"inline-block overflow-hidden\">\n            <span\n              className={cn(glyph, \"translate-y-full opacity-0\", secondaryEnter)}\n              style={{ \"--i\": i } as React.CSSProperties}\n            >\n              {char === \" \" ? \"\\u00A0\" : char}\n            </span>\n          </span>\n        ))}\n      </span>\n    </span>\n  )\n}\n\n/**\n * Fallback when labels aren't plain strings — one clipped stack, same easing.\n */\nfunction LabelRollBlock({\n  children,\n  secondary,\n  className,\n  interactive,\n  durationMs,\n}: {\n  children: React.ReactNode\n  secondary: React.ReactNode\n  className?: string\n  interactive: boolean\n  durationMs: number\n}) {\n  return (\n    <span\n      className={cn(\n        \"group/roll relative inline-flex h-[1.15em] overflow-hidden align-bottom outline-none\",\n        className\n      )}\n      tabIndex={interactive ? 0 : undefined}\n      style={\n        {\n          \"--dur\": `${durationMs}ms`,\n          \"--ease\": EASE,\n        } as React.CSSProperties\n      }\n    >\n      <span\n        className={cn(\n          \"inline-flex flex-col will-change-transform\",\n          \"transition-transform ease-[var(--ease)] [transition-duration:var(--dur)]\",\n          \"motion-reduce:transition-none\",\n          \"group-hover:-translate-y-1/2 group-focus:-translate-y-1/2 group-hover/roll:-translate-y-1/2 group-focus-within/roll:-translate-y-1/2\"\n        )}\n      >\n        <span className=\"inline-block leading-[1.15]\">{children}</span>\n        <span className=\"inline-block leading-[1.15]\" aria-hidden>\n          {secondary}\n        </span>\n      </span>\n    </span>\n  )\n}\n\n/**\n * Clipped label swap on hover / focus — frequent-path delight.\n * Strings → letter-staggered dual layer (primary out, secondary in).\n * Other nodes → whole-line roll. CSS only; reduced motion stays static.\n */\nexport function LabelRoll({\n  children,\n  secondary,\n  className,\n  interactive = false,\n  staggerMs = 18,\n  durationMs = 320,\n}: LabelRollProps) {\n  const second = secondary ?? children\n\n  if (typeof children === \"string\" && typeof second === \"string\") {\n    return (\n      <LabelRollChars\n        primary={children}\n        secondary={second}\n        className={className}\n        interactive={interactive}\n        staggerMs={staggerMs}\n        durationMs={durationMs}\n      />\n    )\n  }\n\n  return (\n    <LabelRollBlock\n      className={className}\n      interactive={interactive}\n      durationMs={durationMs}\n      secondary={second}\n    >\n      {children}\n    </LabelRollBlock>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/label-roll.tsx"
    }
  ],
  "type": "registry:ui"
}