{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "roll-text",
  "title": "Roll Text",
  "description": "A label that rolls to a fresh copy of itself on hover or when its value changes, while assistive tech hears it once.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/roll-text.tsx",
      "content": "import type * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype RollTextVariant = \"word\" | \"letters\" | \"flip\"\ntype RollTextEffect = \"change\"\ntype RollTextTrigger = \"self\" | \"parent\"\n\nconst STEP_MS = 18\n/** A long label must not still be moving after the pointer has left, so the whole wave has a ceiling. */\nconst MAX_WAVE_MS = 240\n\nfunction letterDelay(index: number, count: number) {\n  const step = Math.min(STEP_MS, MAX_WAVE_MS / Math.max(1, count))\n  return Math.round(index * step)\n}\n\n/** A focused link rolls too: keyboard users deserve the same answer as a pointer. `in-` reaches any focused ancestor. */\nconst ROLL_UP: Record<RollTextTrigger, string> = {\n  self: \"group-hover/roll:-translate-y-full in-focus-visible:-translate-y-full\",\n  parent: \"group-hover:-translate-y-full in-focus-visible:-translate-y-full\",\n}\n\nconst TURN_OVER: Record<RollTextTrigger, string> = {\n  self: \"group-hover/roll:[transform:rotateX(90deg)] in-focus-visible:[transform:rotateX(90deg)]\",\n  parent: \"group-hover:[transform:rotateX(90deg)] in-focus-visible:[transform:rotateX(90deg)]\",\n}\n\nconst TRAVEL = \"transition-transform duration-(--duration-base) ease-(--ease-mise)\"\n/** Room for descenders inside the clip. The copy waiting below starts in its own empty line box, so nothing peeks in. */\nconst CLIP = \"overflow-y-clip pb-[0.1em] -mb-[0.1em]\"\n\ninterface VisualProps {\n  text: string\n  trigger: RollTextTrigger\n}\n\nfunction RollWord({ text, trigger }: VisualProps) {\n  return (\n    <span data-slot=\"roll-text-clip\" className={cn(\"block\", CLIP)}>\n      <span className={cn(\"relative block\", TRAVEL, ROLL_UP[trigger])}>\n        <span className=\"block\">{text}</span>\n        <span data-slot=\"roll-text-copy\" className=\"absolute inset-x-0 top-full block\">\n          {text}\n        </span>\n      </span>\n    </span>\n  )\n}\n\nfunction RollLetters({ text, trigger }: VisualProps) {\n  const letters = Array.from(text)\n  const words = text.split(\" \")\n  const starts = words.map((_, position) => words.slice(0, position).reduce((sum, word) => sum + Array.from(word).length + 1, 0))\n  return (\n    <span className=\"block\">\n      {words.map((word, position) => (\n        <span key={`${word}-${starts[position]}`}>\n          <span className=\"inline-block whitespace-nowrap\">\n            {Array.from(word).map((letter, offset) => {\n              const index = starts[position] + offset\n              return (\n                <span key={index} data-slot=\"roll-text-letter\" className={cn(\"inline-block\", CLIP)}>\n                  <span className={cn(\"relative inline-block\", TRAVEL, ROLL_UP[trigger])} style={{ transitionDelay: `${letterDelay(index, letters.length)}ms` }}>\n                    <span className=\"inline-block\">{letter}</span>\n                    <span data-slot=\"roll-text-copy\" className=\"absolute inset-x-0 top-full inline-block\">\n                      {letter}\n                    </span>\n                  </span>\n                </span>\n              )\n            })}\n          </span>\n          {position < words.length - 1 ? \" \" : null}\n        </span>\n      ))}\n    </span>\n  )\n}\n\n/**\n * A real cube: the second copy is the floor, hinged at the bottom edge of the first and folded back out of sight.\n * The rotor turns about the centre of that cube, which is half a line behind the text.\n */\nfunction RollFlip({ text, trigger }: VisualProps) {\n  return (\n    <span data-slot=\"roll-text-stage\" className=\"block [perspective:20em]\">\n      <span className={cn(\"relative block origin-[50%_50%_-0.5lh] [transform-style:preserve-3d]\", TRAVEL, TURN_OVER[trigger])}>\n        <span className=\"block backface-hidden\">{text}</span>\n        <span data-slot=\"roll-text-copy\" className=\"absolute inset-x-0 top-full block origin-top backface-hidden [transform:rotateX(-90deg)]\">\n          {text}\n        </span>\n      </span>\n    </span>\n  )\n}\n\nfunction RollChange({ text }: { text: string }) {\n  return (\n    <span data-slot=\"roll-text-clip\" className={cn(\"relative block\", CLIP)}>\n      {/* popLayout takes the old word out of the flow, or the box would hold both widths while they pass. */}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span key={text} className=\"block\" initial={{ y: \"100%\" }} animate={{ y: 0 }} exit={{ y: \"-100%\" }} transition={SPRING_DEFAULT}>\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  )\n}\n\nconst VISUAL: Record<RollTextVariant, (props: VisualProps) => React.JSX.Element> = {\n  word: RollWord,\n  letters: RollLetters,\n  flip: RollFlip,\n}\n\ntype RollTextProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  text: string\n  /** `word` one block rolls up, `letters` a wave along the label, `flip` the label turns over like a cube. */\n  variant?: RollTextVariant\n  /** `change` stops reacting to hover and rolls whenever `text` changes, for a label that reports a new state. */\n  effect?: RollTextEffect\n  /** `parent` rolls when an ancestor with the `group` class is hovered, so a whole row can drive one label. */\n  trigger?: RollTextTrigger\n}\n\nfunction RollText({ text, variant = \"word\", effect, trigger = \"self\", className, ...props }: RollTextProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const root = { ...props, \"data-slot\": \"roll-text\", \"data-variant\": variant, \"data-effect\": effect }\n  if (reduceMotion) {\n    return (\n      <span {...root} data-reduced=\"true\" className={cn(\"inline-block max-w-full\", className)}>\n        {text}\n      </span>\n    )\n  }\n  const Visual = VISUAL[variant]\n  return (\n    <span {...root} className={cn(\"group/roll relative inline-block max-w-full\", className)}>\n      {/* Letters and a second copy would be read as stutter, so assistive tech gets the plain label once. */}\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\" className=\"block\">\n        {effect === \"change\" ? <RollChange text={text} /> : <Visual text={text} trigger={trigger} />}\n      </span>\n    </span>\n  )\n}\n\nexport { RollText, letterDelay, type RollTextProps, type RollTextVariant, type RollTextEffect, type RollTextTrigger }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}