{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "decode-text",
  "type": "registry:block",
  "title": "Decode Text",
  "description": "Dot-matrix text that decodes out of cycling glyphs, locking left to right with an accent flash, and morphs between phrases keeping the letters that already match. Under the pointer it ripples apart and relocks outward from the cursor.",
  "docs": "Decode Text was added to public/vividsites/decode-text.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/decode-text",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/decode-text.html",
      "type": "registry:file",
      "target": "public/vividsites/decode-text.html",
      "content": "<!--\n  vs-decode-text: text that decodes out of cycling glyphs, each letter settling left to right with a\n  little jitter and a flash of the accent as it locks. Under the pointer it comes apart again: letters\n  near the cursor re-scramble and relock in a ripple that spreads out from it.\n\n  Markup: data-vs-decode-text on any element holding plain text (<br> line breaks are kept).\n    data-vs-decode-text=\"view\"    (default) decodes once when it scrolls into view\n    data-vs-decode-text=\"hover\"   also ripples under the pointer / re-decodes on keyboard focus\n    data-vs-decode-text=\"manual\"  only through the API\n    data-vs-decode-text-set=\"class\" | \"hex\" | \"binary\" | \"blocks\"   the scramble alphabet\n      class (default): glyphs from the same class as the letter (A–Z, a–z, 0–9, symbols), so word\n      shapes keep their density while scrambling.\n  Every letter sits in a fixed 1ch cell and whitespace is held, so nothing reflows mid-scramble.\n  The real text stays readable to screen readers; the scramble is aria-hidden.\n\n  API: VsDecodeText.run(el[, newText]) → Promise. Passing newText morphs from the current string;\n       letters that already match stay put.\n\n  Variables:\n    --vs-decode-text-accent   colour of a letter at the moment it locks   #8dffc4\n    --vs-decode-text-noise    opacity of letters still scrambling         .38\n    --vs-decode-text-stagger  time between letters locking                34ms\n    --vs-decode-text-lead     scramble time before the first letter locks 240ms\n    --vs-decode-text-frame    glyph swap interval                         40ms\n    --vs-decode-text-radius   hover ripple radius                         1.6em\n  Use a monospace face (Bitcount Grid Single here). prefers-reduced-motion: text shown as-is.\n-->\n<style>\n  .vs-decode-text {\n    --vs-decode-text-accent: #8dffc4;\n    --vs-decode-text-noise: .38;\n    --vs-decode-text-stagger: 34ms;\n    --vs-decode-text-lead: 240ms;\n    --vs-decode-text-frame: 40ms;\n    --vs-decode-text-radius: 1.6em;\n    font-variant-ligatures: none;\n    font-feature-settings: \"calt\" 0, \"liga\" 0;\n  }\n  .vs-decode-text-sr {\n    position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;\n    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;\n  }\n  .vs-decode-text-w { white-space: nowrap; }\n  .vs-decode-text-c {\n    display: inline-block; width: 1ch; text-align: center;\n    transition: color 520ms cubic-bezier(.16,1,.3,1), opacity 180ms linear, text-shadow 520ms cubic-bezier(.16,1,.3,1);\n  }\n  .vs-decode-text-c[data-s=\"noise\"] { opacity: var(--vs-decode-text-noise); transition: none; }\n  .vs-decode-text-c[data-s=\"lock\"] {\n    color: var(--vs-decode-text-accent);\n    text-shadow: 0 0 .45em color-mix(in srgb, var(--vs-decode-text-accent) 60%, transparent);\n    transition: none;\n  }\n  [data-vs-decode-text=\"hover\"] { cursor: crosshair; }\n</style>\n<script>\n(() => {\n  if (window.VsDecodeText) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const CLASS = { up: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', lo: 'abcdefghijklmnopqrstuvwxyz', num: '0123456789', sym: '<>[]{}/\\\\=+*#%&?!:;_' };\n  const SETS = { hex: '0123456789ABCDEF', binary: '01', blocks: '░▒▓█▚▞▖▗▘▝' };\n  const classOf = (ch) => /[A-Z]/.test(ch) ? 'up' : /[a-z]/.test(ch) ? 'lo' : /[0-9]/.test(ch) ? 'num' : 'sym';\n  const pick = (ch, set) => { const s = SETS[set] || CLASS[classOf(ch)]; return s[Math.random() * s.length | 0]; };\n  const num = (el, n, fb) => { const v = parseFloat(getComputedStyle(el).getPropertyValue(n)); return isNaN(v) ? fb : v; };\n  const recs = new WeakMap();\n\n  // build: one fixed-width cell per character, words kept on one line; <br> becomes '\\n'\n  function mount(el) {\n    let rec = recs.get(el);\n    if (rec) return rec;\n    const text = [...el.childNodes].map((n) => (n.nodeName === 'BR' ? '\\n' : n.textContent.replace(/\\s+/g, ' '))).join('').replace(/ ?\\n ?/g, '\\n').trim();\n    el.textContent = '';\n    const sr = document.createElement('span'); sr.className = 'vs-decode-text-sr';\n    const vis = document.createElement('span'); vis.setAttribute('aria-hidden', 'true');\n    el.append(sr, vis);\n    rec = { el, sr, vis, text: '', cells: [], raf: 0, lastSwap: 0 };\n    recs.set(el, rec);\n    setText(rec, text);\n    return rec;\n  }\n  function setText(rec, text) {\n    rec.text = text; rec.sr.textContent = text.replace(/\\n/g, ' ');\n    rec.vis.textContent = ''; rec.cells = [];\n    let word = null;\n    for (const ch of text) {\n      if (ch === '\\n') { rec.vis.appendChild(document.createElement('br')); word = null; continue; }\n      if (ch === ' ') { rec.vis.appendChild(document.createTextNode(' ')); word = null; continue; }\n      if (!word) { word = document.createElement('span'); word.className = 'vs-decode-text-w'; rec.vis.appendChild(word); }\n      const c = document.createElement('span'); c.className = 'vs-decode-text-c'; c.textContent = ch;\n      word.appendChild(c); rec.cells.push({ node: c, ch, until: 0, lockedAt: -1 });\n    }\n  }\n\n  // one loop per element: a cell scrambles while now < until, then flashes the accent and hands back\n  function loop(rec) {\n    if (rec.raf) return;\n    const el = rec.el, frame = num(el, '--vs-decode-text-frame', 40), set = el.dataset.vsDecodeTextSet;\n    const tick = (now) => {\n      const swap = now - rec.lastSwap >= frame; if (swap) rec.lastSwap = now;\n      let pending = false;\n      for (const c of rec.cells) {\n        if (now < c.until) {\n          pending = true;\n          if (swap) { c.node.textContent = pick(c.ch, set); c.node.dataset.s = 'noise'; }\n        } else if (c.lockedAt < 0) {\n          c.lockedAt = now; c.node.textContent = c.ch; c.node.dataset.s = 'lock'; pending = true;\n        } else if (now - c.lockedAt < 90) pending = true;\n        else if (c.node.dataset.s) c.node.dataset.s = '';   // hand back to the ink colour, eased by CSS\n      }\n      rec.raf = pending ? requestAnimationFrame(tick) : 0;\n      if (!pending && rec.done) { const d = rec.done; rec.done = null; d(); }\n    };\n    rec.raf = requestAnimationFrame(tick);\n  }\n\n  function run(el, next) {\n    const rec = mount(el);\n    const from = rec.cells.map((c) => c.ch);\n    const morph = typeof next === 'string' && next !== rec.text;\n    if (morph) setText(rec, next);\n    if (reduced) return Promise.resolve();\n    const stagger = num(el, '--vs-decode-text-stagger', 34), lead = num(el, '--vs-decode-text-lead', 240);\n    // per-letter schedule: left to right, with a seeded jitter so it reads organic but replays identically\n    const t0 = performance.now(); let k = 0;\n    rec.cells.forEach((c, i) => {\n      if (morph && from[i] === c.ch) { c.until = 0; c.lockedAt = 0; c.node.dataset.s = ''; return; }\n      const jitter = ((Math.sin((i + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;\n      c.until = t0 + lead + (k++) * stagger + jitter * stagger * 2.4; c.lockedAt = -1;\n    });\n    return new Promise((resolve) => { if (rec.done) rec.done(); rec.done = resolve; loop(rec); });\n  }\n\n  // pointer ripple: cells within the radius scramble, relocking outward from the cursor\n  function ripple(rec, x, y) {\n    const el = rec.el, fs = parseFloat(getComputedStyle(el).fontSize);\n    const R = num(el, '--vs-decode-text-radius', 2.2) * fs, now = performance.now();\n    for (const c of rec.cells) {\n      const r = c.node.getBoundingClientRect();\n      const d = Math.hypot(r.left + r.width / 2 - x, r.top + r.height / 2 - y);\n      if (d > R) continue;\n      const t = now + 160 + (d / R) * 260 + Math.random() * 90;\n      if (t > c.until) { c.until = t; c.lockedAt = -1; }\n    }\n    loop(rec);\n  }\n\n  const ready = Promise.race([document.fonts ? document.fonts.ready : Promise.resolve(), new Promise((r) => setTimeout(r, 2000))]);\n  window.VsDecodeText = { run: (el, next) => ready.then(() => run(el, next)), mount };\n\n  const init = () => {\n    const io = new IntersectionObserver((es) => es.forEach((e) => {\n      if (!e.isIntersecting) return; io.unobserve(e.target); ready.then(() => run(e.target));\n    }), { threshold: .3 });\n    document.querySelectorAll('[data-vs-decode-text]').forEach((el) => {\n      const rec = mount(el);\n      const mode = el.dataset.vsDecodeText || 'view';\n      if (mode !== 'manual') {\n        // hold every letter as noise until it decodes, so there is no flash of the final text\n        if (!reduced) rec.cells.forEach((c) => { c.node.dataset.s = 'noise'; c.node.textContent = pick(c.ch, el.dataset.vsDecodeTextSet); });\n        io.observe(el);\n      }\n      if (mode === 'hover' && !reduced) {\n        let last = 0;\n        el.addEventListener('pointermove', (e) => { if (e.timeStamp - last < 30) return; last = e.timeStamp; ripple(rec, e.clientX, e.clientY); });\n        (el.closest('a,button,[tabindex]') || el).addEventListener('focus', () => run(el));\n      }\n    });\n  };\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n\n<!-- usage -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Bitcount+Grid+Single:wght@300..500&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#07090c;color:#d8f5e6;padding:14vh 7vw;font-family:'Bitcount Grid Single',monospace\">\n  <h2 class=\"vs-decode-text\" data-vs-decode-text=\"hover\"\n      style=\"margin:0;font-weight:300;font-size:clamp(2.2rem,7vw,6rem);line-height:1;letter-spacing:0\">\n    Listening to<br>the quiet sky\n  </h2>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "scramble",
      "decode",
      "text effect",
      "hover",
      "monospace",
      "glitch",
      "hacker"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Bitcount+Grid+Single:wght@300..500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#8dffc4",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/decode-text/index.html",
    "thumbnail": "https://vividsites.app/api/component/decode-text/thumb.jpg",
    "video": "https://vividsites.app/api/component/decode-text/thumb.mp4",
    "page": "https://vividsites.app/c/decode-text"
  }
}