{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scramble-text","type":"registry:component","title":"Scramble Text","description":"A decoding headline: every character resolves left to right out of random glyphs inside a fixed cell, so the line never changes width. Runs on hover, focus, tap or when it scrolls into view, with a glowing decode head and a lock flash.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/scramble-text.tsx","content":"\"use client\";\n\n/*\n * ScrambleText: a decoding headline.\n *\n * Every character is its own fixed cell, sized by the REAL character, so the\n * line never changes width while it decodes, in monospace or in a proportional\n * face. Cells resolve left to right with a small deterministic stagger. The\n * character that is about to lock carries a glowing \"head\"; a cell that has\n * just locked flashes the accent and settles into the text colour. Runs on\n * hover, keyboard focus and tap, and (trigger=\"mount\") once when it scrolls\n * into view. Reduced motion swaps the scramble for a soft fade.\n */\n\nimport { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from \"react\";\n\nconst CHARS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*\";\nconst HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;\nconst EASE = \"cubic-bezier(0.2, 0.7, 0.2, 1)\";\n\n/** 0 resolved, 1 scrambling, 2 scrambling and next to lock (the head), 3 locked this tick. */\ntype Phase = 0 | 1 | 2 | 3;\n\ninterface Frame {\n  text: string;\n  glyphs: string[];\n  /** Horizontal squeeze that fits each glyph to its cell (proportional faces only). */\n  fit: number[];\n  phase: Phase[];\n}\n\n/** The tick each character locks on: a lead of scrambles, a stagger that tightens for long lines, a little fixed jitter. */\nfunction lockTicks(chars: string[]): number[] {\n  const step = Math.min(3, 54 / Math.max(chars.length, 1));\n  return chars.map((c, i) => (c.trim() === \"\" ? 0 : Math.round(4 + i * step + ((i * 7) % 3) * 0.6)));\n}\n\nfunction cellStyle(p: Phase, accent: string): CSSProperties {\n  if (p >= 2) {\n    return { color: accent, textShadow: \"0 0 \" + (p === 2 ? 14 : 18) + \"px \" + accent, transition: \"none\" };\n  }\n  const t = \"color 520ms \" + EASE + \", text-shadow 520ms \" + EASE;\n  return { color: \"inherit\", textShadow: \"0 0 0 transparent\", transition: t };\n}\n\nexport function ScrambleText({\n  text,\n  speed = 30,\n  trigger = \"hover\",\n  font = \"mono\",\n  glyphs = CHARS,\n  accent = \"#8ab4ff\",\n  delay = 0,\n  as = \"button\",\n  replayKey = 0,\n  className,\n}: {\n  text: string;\n  /** ms between scramble frames. Lower is faster and busier. */\n  speed?: number;\n  /** \"hover\" decodes on hover, focus and tap. \"mount\" also decodes once when it scrolls into view. */\n  trigger?: \"hover\" | \"mount\";\n  /** \"mono\" locks the face to monospace; \"sans\" inherits the surrounding font. Width never jumps in either. */\n  font?: \"mono\" | \"sans\";\n  /** The characters the scramble draws from. */\n  glyphs?: string;\n  /** Colour of the decoding head and the lock flash (hex). */\n  accent?: string;\n  /** ms to wait before a mount decode starts. Handy for staggering lines. */\n  delay?: number;\n  /** \"span\" renders non-interactive text, e.g. inside a button. Drive it with replayKey. */\n  as?: \"button\" | \"span\";\n  /** Change this number to replay the decode from outside. */\n  replayKey?: number;\n  /** Replaces the default look (size, weight, tracking). */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLElement | null>(null);\n  const runRef = useRef<(soft?: boolean) => void>(() => {});\n  const lastKey = useRef(replayKey);\n  const [frame, setFrame] = useState<Frame | null>(null);\n  const tint = HEX.test(accent) ? accent : \"#8ab4ff\";\n\n  useEffect(() => {\n    const chars = Array.from(text);\n    const pool = Array.from(glyphs.trim() ? glyphs : CHARS);\n    const locks = lockTicks(chars);\n    const last = Math.max(0, ...locks);\n    const ms = Math.max(10, speed);\n    let timer: ReturnType<typeof setTimeout> | undefined;\n    let wait: ReturnType<typeof setTimeout> | undefined;\n    let io: IntersectionObserver | undefined;\n    let running = false;\n    const widths = new Map<string, number>();\n    let ctx: CanvasRenderingContext2D | null = null;\n    // Proportional faces: measure glyphs so a wide one is squeezed into a narrow cell.\n    const measure = (c: string) => {\n      let w = widths.get(c);\n      if (w === undefined) {\n        w = ctx ? ctx.measureText(c).width : 0;\n        widths.set(c, w);\n      }\n      return w;\n    };\n\n    // A timer rather than requestAnimationFrame: the tick RATE is the effect,\n    // and rAF would tie it to the display's refresh rate.\n    const run = (soft?: boolean) => {\n      if (soft && running) return;\n      clearTimeout(timer);\n      if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n        rootRef.current?.animate?.([{ opacity: 0.4 }, { opacity: 1 }], { duration: 320, easing: \"ease-out\" });\n        return;\n      }\n      running = true;\n      ctx = null;\n      const el = rootRef.current;\n      if (font === \"sans\" && el) {\n        const cs = getComputedStyle(el);\n        ctx = document.createElement(\"canvas\").getContext(\"2d\");\n        if (ctx) ctx.font = cs.fontStyle + \" \" + cs.fontWeight + \" \" + cs.fontSize + \" \" + cs.fontFamily;\n        widths.clear();\n      }\n      let t = 0;\n      const tick = () => {\n        if (t > last + 1) {\n          running = false;\n          setFrame(null);\n          return;\n        }\n        let head = -1;\n        const phase: Phase[] = [];\n        const out: string[] = [];\n        const fit: number[] = [];\n        chars.forEach((c, i) => {\n          const lock = locks[i] ?? 0;\n          if (c.trim() === \"\" || lock < t) {\n            phase.push(0);\n            out.push(c);\n            fit.push(1);\n          } else if (lock === t) {\n            phase.push(3);\n            out.push(c);\n            fit.push(1);\n          } else {\n            if (head < 0) head = i;\n            phase.push(head === i ? 2 : 1);\n            const g = pool[Math.floor(Math.random() * pool.length)] ?? \"#\";\n            out.push(g);\n            const gw = ctx ? measure(g) : 0;\n            fit.push(gw > 0 ? Math.min(1, (measure(c) * 1.1) / gw) : 1);\n          }\n        });\n        setFrame({ text, glyphs: out, fit, phase });\n        t += 1;\n        timer = setTimeout(tick, ms);\n      };\n      timer = setTimeout(tick, 0);\n    };\n    runRef.current = run;\n\n    if (trigger === \"mount\") {\n      const go = () => {\n        wait = setTimeout(() => run(), Math.max(0, delay));\n      };\n      const el = rootRef.current;\n      if (el && typeof IntersectionObserver !== \"undefined\") {\n        io = new IntersectionObserver(\n          (entries) => {\n            if (entries.some((e) => e.isIntersecting)) {\n              io?.disconnect();\n              go();\n            }\n          },\n          { threshold: 0.4 },\n        );\n        io.observe(el);\n      } else {\n        go();\n      }\n    }\n\n    return () => {\n      clearTimeout(timer);\n      clearTimeout(wait);\n      io?.disconnect();\n      running = false;\n    };\n  }, [text, speed, trigger, glyphs, delay, font]);\n\n  useEffect(() => {\n    if (lastKey.current === replayKey) return;\n    lastKey.current = replayKey;\n    runRef.current();\n  }, [replayKey]);\n\n  const live = frame && frame.text === text ? frame : null;\n  const nodes: ReactNode[] = [];\n  let at = 0;\n  text.split(\" \").forEach((word, wi) => {\n    if (wi > 0) {\n      nodes.push(\" \");\n      at += 1;\n    }\n    const letters = Array.from(word);\n    if (letters.length === 0) return;\n    nodes.push(\n      <span key={\"w\" + at} className=\"inline-block whitespace-nowrap\">\n        {letters.map((ch, k) => {\n          const i = at + k;\n          const p: Phase = live?.phase[i] ?? 0;\n          const scrambling = p === 1 || p === 2;\n          return (\n            <span key={i} className=\"relative inline-block\" style={cellStyle(p, tint)}>\n              <span style={{ opacity: scrambling ? 0 : 1 }}>{ch}</span>\n              {scrambling && (\n                <span\n                  className=\"absolute inset-0 flex items-center justify-center whitespace-pre\"\n                  style={{ opacity: p === 2 ? 1 : 0.42, transform: \"scaleX(\" + (live?.fit[i] ?? 1) + \")\" }}\n                >\n                  {live?.glyphs[i]}\n                </span>\n              )}\n            </span>\n          );\n        })}\n      </span>,\n    );\n    at += letters.length;\n  });\n\n  const look = className ?? \"text-2xl font-bold tracking-tight\";\n  const cls = (font === \"mono\" ? \"font-mono \" : \"\") + look;\n  const inner = (\n    <>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">{nodes}</span>\n    </>\n  );\n  const setRef = (el: HTMLElement | null) => {\n    rootRef.current = el;\n  };\n  const onEnter = (e: ReactPointerEvent<HTMLElement>) => {\n    if (e.pointerType !== \"touch\") runRef.current();\n  };\n\n  if (as === \"span\") {\n    return (\n      <span ref={setRef} className={cls} onPointerEnter={onEnter}>\n        {inner}\n      </span>\n    );\n  }\n  return (\n    <button\n      ref={setRef}\n      type=\"button\"\n      aria-label={text}\n      className={\n        \"cursor-pointer touch-manipulation rounded-md bg-transparent p-0 text-left select-none focus-visible:!outline-2 focus-visible:!outline-offset-4 focus-visible:!outline-[#8ab4ff] \" +\n        cls\n      }\n      onPointerEnter={onEnter}\n      onFocus={() => runRef.current(true)}\n      onClick={() => runRef.current(true)}\n    >\n      {inner}\n    </button>\n  );\n}\n","type":"registry:component","target":"components/viberdy/scramble-text.tsx"}],"meta":{"tier":"free","tags":["text","hover","decode","scramble","headline","focus","stable-width","mono"],"href":"https://viberdy.dev/library/scramble-text","aiPrompt":"Create a React + Tailwind \"decoding headline\" component named ScrambleText. One self-contained \"use client\" file, React only (useEffect, useRef, useState) - no animation or scramble library.\n\nProps (all optional except text): text (string); speed (ms between frames, default 30, min 10); trigger (\"hover\" | \"mount\", default \"hover\"); font (\"mono\" | \"sans\", default \"mono\"); glyphs (string, default \"ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*\"); accent (hex colour, default \"#8ab4ff\", validate with /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i); delay (ms before a mount decode, default 0); as (\"button\" | \"span\", default \"button\"); replayKey (number; changing it replays); className (replaces the default look \"text-2xl font-bold tracking-tight\").\n\nStructure: split the text on spaces into words; each word is an inline-block whitespace-nowrap span, words are separated by plain text spaces so the line still wraps. Each character (use Array.from) is a \"relative inline-block\" cell containing the REAL character as normal-flow content (it sizes the cell, so width never changes) plus, while scrambling, an absolutely positioned overlay (inset-0, flex, centred, whitespace-pre) showing the random glyph while the real character goes opacity 0. font \"mono\" adds Tailwind font-mono; \"sans\" inherits. Root is a type=\"button\" (cursor-pointer, bg transparent, no padding, rounded-md, text-left, select-none) or a span for as=\"span\". Inside: a sr-only span with the text and an aria-hidden span with the cells; the button also has aria-label={text}.\n\nSchedule: lock tick per character i = round(4 + i*step + ((i*7)%3)*0.6) with step = min(3, 54/length); whitespace locks at 0. Run a setTimeout loop (not requestAnimationFrame, the tick RATE is the effect) every `speed` ms; first tick scheduled with setTimeout 0 (never set state synchronously in an effect). At tick t each char is: resolved (lock < t), flash (lock === t), or scrambling with a fresh random glyph; the first scrambling char is the \"head\". Keep a frame state {text, glyphs[], phase[]} (phase 0 resolved, 1 scrambling, 2 head, 3 flash); after tick last+1 set the frame to null. Ignore frames whose text differs from the current text.\n\nVisuals: scrambling overlay opacity 0.42 in the text colour; head overlay opacity 1 in the accent with text-shadow 0 0 14px accent; flash cell colour accent with text-shadow 0 0 18px accent and transition none; resolved cells colour inherit, text-shadow 0 0 0 transparent, transition \"color 520ms, text-shadow 520ms\" with cubic-bezier(0.2,0.7,0.2,1) so the accent eases back.\n\nTriggers: pointerenter (ignore pointerType touch) always restarts; focus and click start only if not already running (so hover then click does not restart); trigger \"mount\" also observes the element with IntersectionObserver (threshold 0.4), disconnects on first hit and runs after `delay` ms. A replayKey change (skip the first render) restarts. All timers and the observer are cleared on unmount.\n\nAccessibility: visible focus ring (focus-visible outline 2px, offset 4, accent colour, use important utilities because host CSS may set outlines), real text always exposed to assistive tech, the animation aria-hidden. prefers-reduced-motion (checked with matchMedia when a run starts): do not scramble, instead animate the root opacity 0.4 to 1 over 320ms with WAAPI. Touch: tap replays via click.\n\nProportional fit: when font is \"sans\", measure glyph widths with a canvas 2D context set to the root's computed font (cache per run) and apply transform scaleX(min(1, realCharWidth*1.1 / glyphWidth)) to each scrambling overlay, so a wide random glyph is squeezed into a narrow cell instead of overlapping its neighbours. Mono needs no fit.","license":"https://viberdy.dev/license"}}