{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scramble-text",
  "title": "Scramble Text",
  "description": "Decodes left to right from block glyphs, on mount and on hover.",
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/scramble-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/spotcheck/hooks/use-reduced-motion\"\n\nconst GLYPHS = \"█▓▒░/\\\\<>_#01\"\nconst FRAMES_PER_CHARACTER = 2.4\nconst SETTLE_FRAMES = 3\n\n/** The text at a given frame: settled on the left, glyphs on the right, spaces left alone. */\nexport function scrambleFrame(text: string, frame: number, random: () => number = Math.random) {\n  return Array.from(text, (char, index) => {\n    if (char === \" \" || index < frame / FRAMES_PER_CHARACTER) return char\n    return GLYPHS[Math.floor(random() * GLYPHS.length)]\n  }).join(\"\")\n}\n\ntype ScrambleTextProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  text: string\n  /** When it decodes. Pass an empty array to show the text with no effect. */\n  trigger?: (\"mount\" | \"hover\")[]\n}\n\nexport function ScrambleText({ text, trigger = [\"mount\", \"hover\"], className, ...props }: ScrambleTextProps) {\n  // Null shows the real text, so a new `text` always appears even if nothing is playing.\n  const [output, setOutput] = React.useState<string | null>(null)\n  const reduced = useReducedMotion()\n  const frame = React.useRef(0)\n  const onMount = trigger.includes(\"mount\")\n\n  const play = React.useCallback(() => {\n    cancelAnimationFrame(frame.current)\n    if (reduced) return\n\n    const lastFrame = text.length * FRAMES_PER_CHARACTER + SETTLE_FRAMES\n    let count = 0\n    const tick = () => {\n      count++\n      setOutput(count < lastFrame ? scrambleFrame(text, count) : null)\n      if (count < lastFrame) frame.current = requestAnimationFrame(tick)\n    }\n    frame.current = requestAnimationFrame(tick)\n  }, [reduced, text])\n\n  React.useEffect(() => {\n    if (onMount) play()\n    return () => cancelAnimationFrame(frame.current)\n  }, [onMount, play])\n\n  return (\n    <span\n      className={cn(\"spotcheck-display relative inline-grid\", className)}\n      onMouseEnter={trigger.includes(\"hover\") ? play : undefined}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {/* The real text sets the size, so glyphs of other widths can't move the layout. */}\n      <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n        {text}\n      </span>\n      {/* Zero size with a full minimum, so the glyphs fill the cell but can't widen it. */}\n      <span aria-hidden className=\"col-start-1 row-start-1 h-0 min-h-full w-0 min-w-full overflow-hidden\">\n        {output ?? text}\n      </span>\n    </span>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}