{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blur-reveal",
  "type": "registry:block",
  "title": "Blur Reveal Headline",
  "description": "A headline that rises out of a blur word by word or letter by letter, each unit on its own interruptible spring, with a quicker reversed exit. Inline italics and dimmed clauses survive the split.",
  "docs": "Blur Reveal Headline was added to public/vividsites/blur-reveal.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/blur-reveal",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/blur-reveal.html",
      "type": "registry:file",
      "target": "public/vividsites/blur-reveal.html",
      "content": "<!--\n  vs-blur-reveal: a headline that arrives word by word (or letter by letter), each unit rising out of a\n  blur on its own spring. Interruptible: calling play/reverse mid-flight carries each unit's velocity.\n\n  Markup: put data-vs-blur-reveal on any heading. Inline children (<em>, <span class=\"…\">) are kept,\n  so an italic word or a dimmed second clause survives the split.\n    data-vs-blur-reveal=\"view\"       (default) plays once when 30% of it scrolls into view\n    data-vs-blur-reveal=\"manual\"     waits for VsBlurReveal.play(el)\n    data-vs-blur-reveal-unit=\"letters\"   split per letter (letters inside a word never break apart)\n\n  Splitting waits for document.fonts.ready so units are measured in the real face.\n  API (all return Promises): VsBlurReveal.play(el) · .reverse(el) (exit, stagger reversed, quicker spring)\n                             VsBlurReveal.resplit(el) (re-read the unit attribute, reset to hidden)\n\n  Variables (on the element or any ancestor):\n    --vs-blur-reveal-rise       how far each unit rises                 .42em\n    --vs-blur-reveal-blur       starting blur                           12px\n    --vs-blur-reveal-scale      starting scale                          .96\n    --vs-blur-reveal-stagger    delay between words                     90ms\n    --vs-blur-reveal-letter-stagger  delay between letters              26ms\n    --vs-blur-reveal-tension    spring tension                          88\n    --vs-blur-reveal-friction   spring friction (≥ 2·√tension = no overshoot)   30\n    --vs-blur-reveal-exit-tension / -exit-friction   quicker exit spring   170 / 26\n  prefers-reduced-motion: the text is simply shown.\n-->\n<style>\n  .vs-blur-reveal {\n    --vs-blur-reveal-rise: .42em;\n    --vs-blur-reveal-blur: 12px;\n    --vs-blur-reveal-scale: .96;\n    --vs-blur-reveal-stagger: 90ms;\n    --vs-blur-reveal-letter-stagger: 26ms;\n    --vs-blur-reveal-tension: 88;\n    --vs-blur-reveal-friction: 30;\n    --vs-blur-reveal-exit-tension: 170;\n    --vs-blur-reveal-exit-friction: 26;\n  }\n  /* hidden only while JS is about to split it, so no-JS pages still show the text */\n  .vs-js .vs-blur-reveal:not([data-vs-split]) { visibility: hidden; }\n  .vs-blur-reveal-u {\n    display: inline-block;\n    white-space: pre;\n    opacity: 0;\n    transform-origin: 50% 80%;\n    will-change: transform, opacity, filter;\n  }\n  .vs-blur-reveal[data-vs-done] .vs-blur-reveal-u { will-change: auto; }\n  .vs-blur-reveal-w { display: inline-block; white-space: nowrap; }\n  @media (prefers-reduced-motion: reduce) {\n    .vs-blur-reveal-u { opacity: 1 !important; transform: none !important; filter: none !important; }\n  }\n</style>\n<script>\n(() => {\n  document.documentElement.classList.add('vs-js');\n  if (window.VsBlurReveal) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const cs = (el, name) => getComputedStyle(el).getPropertyValue(name).trim();\n  const num = (el, name, fb) => { const v = parseFloat(cs(el, name)); return isNaN(v) ? fb : v; };\n  const state = new WeakMap();\n\n  // wrap every word (or letter) of every text node in a span, keeping inline elements intact\n  function split(el) {\n    if (el.dataset.vsSplit) return;\n    if (el._vsSrc == null) el._vsSrc = el.innerHTML; else el.innerHTML = el._vsSrc;\n    const letters = el.dataset.vsBlurRevealUnit === 'letters';\n    el.setAttribute('aria-label', el.textContent.replace(/\\s+/g, ' ').trim());\n    const walk = (node) => {\n      for (const child of [...node.childNodes]) {\n        if (child.nodeType === 1) { child.setAttribute('aria-hidden', 'true'); walk(child); continue; }\n        if (child.nodeType !== 3) continue;\n        const frag = document.createDocumentFragment();\n        for (const part of child.textContent.split(/(\\s+)/)) {\n          if (!part) continue;\n          if (/^\\s+$/.test(part)) { frag.appendChild(document.createTextNode(part.includes('\\n') ? ' ' : part)); continue; }\n          const mk = (t) => { const u = document.createElement('span'); u.className = 'vs-blur-reveal-u'; u.textContent = t; return u; };\n          if (letters) {\n            const w = document.createElement('span'); w.className = 'vs-blur-reveal-w'; w.setAttribute('aria-hidden', 'true');\n            for (const ch of part) w.appendChild(mk(ch));\n            frag.appendChild(w);\n          } else { const u = mk(part); u.setAttribute('aria-hidden', 'true'); frag.appendChild(u); }\n        }\n        child.replaceWith(frag);\n      }\n    };\n    walk(el);\n    state.delete(el);\n    el.dataset.vsSplit = '1';\n  }\n\n  // one spring per unit: semi-implicit Euler at 1/120 s sub-steps, frame delta clamped to 64 ms\n  function run(el, goal) {\n    split(el);\n    const units = [...el.querySelectorAll('.vs-blur-reveal-u')];\n    const riseRaw = cs(el, '--vs-blur-reveal-rise') || '.42em';\n    const rise = parseFloat(riseRaw) || 0, riseUnit = (riseRaw.match(/[a-z%]+$/) || ['em'])[0];\n    const blur = num(el, '--vs-blur-reveal-blur', 12), sc = num(el, '--vs-blur-reveal-scale', .96);\n    const out = goal === 0, letters = el.dataset.vsBlurRevealUnit === 'letters';\n    const stagger = num(el, letters ? '--vs-blur-reveal-letter-stagger' : '--vs-blur-reveal-stagger', letters ? 26 : 90) * (out ? .5 : 1);\n    const k = out ? num(el, '--vs-blur-reveal-exit-tension', 170) : num(el, '--vs-blur-reveal-tension', 88);\n    const c = out ? num(el, '--vs-blur-reveal-exit-friction', 26) : num(el, '--vs-blur-reveal-friction', 30);\n    let st = state.get(el);\n    if (!st || st.length !== units.length) { st = units.map(() => ({ x: 0, v: 0 })); state.set(el, st); }\n    const n = units.length;\n    delete el.dataset.vsDone;\n    const paint = (u, s) => {\n      const q = 1 - s.x, dir = out ? -.6 : 1;         // leaving: drift up and away\n      u.style.opacity = Math.max(0, Math.min(1, s.x)).toFixed(3);\n      u.style.transform = Math.abs(q) < .0005 ? 'none' : `translate3d(0,${(q * rise * dir).toFixed(4)}${riseUnit},0) scale(${(1 - q * (1 - sc)).toFixed(4)})`;\n      u.style.filter = Math.abs(q * blur) < .05 ? 'none' : `blur(${Math.abs(q * blur).toFixed(2)}px)`;\n    };\n    if (reduced) { st.forEach((s, i) => { s.x = goal; s.v = 0; paint(units[i], s); }); el.dataset.vsDone = '1'; return Promise.resolve(); }\n    if (el._vsRaf) cancelAnimationFrame(el._vsRaf);\n    if (el._vsResolve) el._vsResolve();\n    return new Promise((resolve) => {\n      el._vsResolve = resolve;\n      const t0 = performance.now(); let last = t0;\n      const tick = (now) => {\n        const dt = Math.min(64, now - last) / 1000; last = now;\n        let moving = false;\n        for (let i = 0; i < n; i++) {\n          const order = out ? n - 1 - i : i;             // exits reverse the stagger\n          const s = st[i];\n          if (s.x === goal && s.v === 0) continue;\n          if (now - t0 < order * stagger) { moving = true; continue; }\n          for (let h = 0; h < dt; h += 1 / 120) {\n            const step = Math.min(1 / 120, dt - h);\n            s.v += (-k * (s.x - goal) - c * s.v) * step;\n            s.x += s.v * step;\n          }\n          if (Math.abs(s.v) < .001 && Math.abs(s.x - goal) < .0005) { s.x = goal; s.v = 0; } else moving = true;\n          paint(units[i], s);\n        }\n        if (moving) el._vsRaf = requestAnimationFrame(tick);\n        else { el.dataset.vsDone = '1'; el._vsRaf = 0; el._vsResolve = null; resolve(); }\n      };\n      el._vsRaf = requestAnimationFrame(tick);\n    });\n  }\n\n  const ready = Promise.race([document.fonts ? document.fonts.ready : Promise.resolve(), new Promise((r) => setTimeout(r, 2500))]);\n  window.VsBlurReveal = {\n    play: (el) => ready.then(() => run(el, 1)),\n    reverse: (el) => ready.then(() => run(el, 0)),\n    resplit: (el) => ready.then(() => { if (el._vsRaf) cancelAnimationFrame(el._vsRaf); delete el.dataset.vsSplit; delete el.dataset.vsDone; split(el); }),\n  };\n\n  const init = () => ready.then(() => {\n    const io = new IntersectionObserver((entries) => {\n      for (const e of entries) if (e.isIntersecting) { io.unobserve(e.target); run(e.target, 1); }\n    }, { threshold: .3 });\n    document.querySelectorAll('[data-vs-blur-reveal]').forEach((el) => {\n      split(el);\n      if (el.dataset.vsBlurReveal !== 'manual') io.observe(el);\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=Instrument+Serif:ital@0;1&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#f3eee6;padding:16vh 7vw\">\n  <h1 class=\"vs-blur-reveal\" data-vs-blur-reveal\n      style=\"margin:0;font:400 clamp(2.8rem,8vw,7.5rem)/.95 'Instrument Serif',serif;letter-spacing:-.025em;color:#1a1612;max-width:12ch\">\n    Light arrives <em style=\"color:#b8492a\">before</em> <span style=\"opacity:.45\">the room does.</span>\n  </h1>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "text reveal",
      "headline",
      "blur",
      "spring",
      "stagger",
      "split text",
      "hero"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#b8492a",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/blur-reveal/index.html",
    "thumbnail": "https://vividsites.app/api/component/blur-reveal/thumb.jpg",
    "video": "https://vividsites.app/api/component/blur-reveal/thumb.mp4",
    "page": "https://vividsites.app/c/blur-reveal"
  }
}