{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mask-lines",
  "type": "registry:block",
  "title": "Masked Line Reveal",
  "description": "Text that rises line by line out of clipped masks as each line scrolls in, then leaves through the top edge like a conveyor. Lines are measured after fonts load, keep inline styling, and re-measure on resize.",
  "docs": "Masked Line Reveal was added to public/vividsites/mask-lines.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/mask-lines",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/mask-lines.html",
      "type": "registry:file",
      "target": "public/vividsites/mask-lines.html",
      "content": "<!--\n  vs-mask-lines: text that rises line by line out of a clipped mask as each line scrolls into view,\n  and leaves the same way, through the top edge, so lines read as a conveyor rather than a fade.\n\n  Markup: put data-vs-mask-lines on any block of text (h1–h6, p, blockquote…). Inline children\n  (<em>, <a>, <strong>) are kept. Lines are measured AFTER document.fonts.ready, in the real face,\n  and re-measured on width change without replaying what is already shown.\n    data-vs-mask-lines           each line plays once, when its own top crosses the trigger line\n    data-vs-mask-lines=\"repeat\"  lines also drop back when scrolled away below the fold\n    data-vs-mask-lines=\"manual\"  no scroll trigger: drive it with VsMaskLines.play(el) / .exit(el)\n    data-vs-mask-lines-dir=\"down\"  lines drop in from above instead of rising\n  Lines that cross the trigger in the same frame queue up with --vs-mask-lines-stagger between them.\n\n  Variables:\n    --vs-mask-lines-stagger   gap between lines entering together      90ms\n    --vs-mask-lines-tilt      starting rotation of each line           4deg\n    --vs-mask-lines-trigger   how far up the viewport a line must be   12%\n    --vs-mask-lines-tension / -friction   the spring (120 / 24, a hair of overshoot)\n  JS: VsMaskLines.refresh(el) after changing text · .play(el) / .exit(el) return Promises.\n  prefers-reduced-motion: text is shown, unsplit.\n-->\n<style>\n  .vs-mask-lines {\n    --vs-mask-lines-stagger: 90ms;\n    --vs-mask-lines-tilt: 4deg;\n    --vs-mask-lines-trigger: 12%;\n    --vs-mask-lines-tension: 120;\n    --vs-mask-lines-friction: 24;\n  }\n  .vs-js .vs-mask-lines:not([data-vs-split]) { visibility: hidden; }\n  /* the mask: padded so ascenders, descenders and italic overhang are not cropped at tight leading */\n  .vs-mask-lines-l {\n    display: block; overflow: hidden;\n    padding: .12em .14em .16em; margin: -.12em -.14em -.16em;\n  }\n  .vs-mask-lines-i {\n    display: block; transform-origin: 0 100%;\n    transform: translate3d(0, 112%, 0) rotate(var(--vs-mask-lines-tilt));\n    will-change: transform;\n  }\n  .vs-mask-lines-l[data-vs-in=\"settled\"] .vs-mask-lines-i { will-change: auto; }\n  @media (prefers-reduced-motion: reduce) {\n    .vs-js .vs-mask-lines:not([data-vs-split]) { visibility: visible; }\n    .vs-mask-lines-i { transform: none !important; }\n  }\n</style>\n<script>\n(() => {\n  document.documentElement.classList.add('vs-js');\n  if (window.VsMaskLines) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const cssNum = (el, n, fb) => { const v = parseFloat(getComputedStyle(el).getPropertyValue(n)); return isNaN(v) ? fb : v; };\n  const items = new Map(); // el -> { html, lines: [{wrap, inner, x, v, goal, due}] }\n  // x: 0 = parked below the mask, 1 = in place, 2 = gone through the top (then parked at 0 again)\n\n  // 1. break the text into word spans that remember their inline ancestors\n  function tokens(root) {\n    const out = [];\n    const walk = (node, chain) => {\n      for (const ch of node.childNodes) {\n        if (ch.nodeType === 1) {\n          if (ch.tagName === 'BR') { out.push({ br: true }); continue; }\n          walk(ch, [...chain, ch]);\n        } else if (ch.nodeType === 3) {\n          for (const part of ch.textContent.split(/(\\s+)/)) if (part) out.push({ text: part, chain, space: /^\\s+$/.test(part) });\n        }\n      }\n    };\n    walk(root, []);\n    return out;\n  }\n  const wrapChain = (text, chain) => {\n    let node = document.createTextNode(text);\n    for (let i = chain.length - 1; i >= 0; i--) { const c = chain[i].cloneNode(false); c.appendChild(node); node = c; }\n    return node;\n  };\n\n  // 2. lay the words out, read which line each lands on, then rebuild as masked lines\n  function build(el) {\n    const rec = items.get(el) || { html: el.innerHTML, label: el.textContent.replace(/\\s+/g, ' ').trim(), lines: [], visible: true };\n    const shown = rec.lines.filter((l) => l.goal === 1).length;\n    el.innerHTML = rec.html;\n    const toks = tokens(el);\n    el.textContent = '';\n    const spans = [];\n    for (const t of toks) {\n      if (t.br) { const b = document.createElement('br'); el.appendChild(b); spans.push({ br: b }); continue; }\n      const s = document.createElement('span');\n      s.appendChild(wrapChain(t.text, t.chain));\n      el.appendChild(s);\n      spans.push({ s, space: t.space });\n    }\n    const rows = []; let top = null;\n    for (const sp of spans) {\n      if (sp.br) { top = null; continue; }\n      const y = sp.s.offsetTop;\n      if (sp.space && rows.length) { rows[rows.length - 1].push(sp.s); continue; }\n      if (top === null || Math.abs(y - top) > 2) { rows.push([]); top = y; }\n      rows[rows.length - 1].push(sp.s);\n    }\n    el.textContent = '';\n    el.setAttribute('aria-label', rec.label);\n    rec.cfg = cfgOf(el);\n    rec.lines = rows.map((row, i) => {\n      const wrap = document.createElement('span'); wrap.className = 'vs-mask-lines-l'; wrap.setAttribute('aria-hidden', 'true');\n      const inner = document.createElement('span'); inner.className = 'vs-mask-lines-i';\n      // drop the trailing space so right-aligned / centred text measures true\n      while (row.length && /^\\s+$/.test(row[row.length - 1].textContent)) row.pop();\n      row.forEach((s) => { while (s.firstChild) inner.appendChild(s.firstChild); });\n      wrap.appendChild(inner); el.appendChild(wrap);\n      const on = i < shown;\n      const line = { wrap, inner, x: on ? 1 : 0, v: 0, goal: on ? 1 : 0, due: 0 };\n      paint(line, rec.cfg);\n      return line;\n    });\n    items.set(el, rec);\n    el.dataset.vsSplit = '1';\n    observe(el);\n  }\n  const cfgOf = (el) => ({\n    k: cssNum(el, '--vs-mask-lines-tension', 120), c: cssNum(el, '--vs-mask-lines-friction', 24),\n    stagger: cssNum(el, '--vs-mask-lines-stagger', 90), tilt: cssNum(el, '--vs-mask-lines-tilt', 4),\n    trigger: cssNum(el, '--vs-mask-lines-trigger', 12) / 100, dir: el.dataset.vsMaskLinesDir === 'down' ? -1 : 1,\n  });\n\n  function paint(l, cfg) {\n    const q = 1 - l.x;                                   // 1 → parked, 0 → in place, −1 → gone\n    l.inner.style.transform = Math.abs(q) < .0005 ? 'none'\n      : `translate3d(0,${(q * 112 * cfg.dir).toFixed(3)}%,0) rotate(${(q * cfg.tilt * cfg.dir).toFixed(3)}deg)`;\n    l.wrap.dataset.vsIn = l.goal === 1 && Math.abs(q) < .0005 ? 'settled' : '';\n  }\n\n  // 3. one shared rAF, awake only while scrolling or settling; each line decides for itself whether it is past the trigger line\n  let raf = 0, last = 0;\n  function frame(now) {\n    const dt = Math.min(64, now - (last || now)) / 1000; last = now;\n    const vh = innerHeight; let busy = false;\n    items.forEach((rec, el) => {\n      if (!rec.visible) return;\n      const { k, c, stagger } = rec.cfg;\n      const mode = el.dataset.vsMaskLines;\n      const trig = vh * (1 - rec.cfg.trigger);\n      let queue = 0;\n      for (const l of rec.lines) {\n        if (mode !== 'manual') {\n          const r = l.wrap.getBoundingClientRect();\n          const want = r.top < trig ? 1 : (mode === 'repeat' && r.top > vh ? 0 : l.goal);\n          if (want !== l.goal) { l.goal = want; l.due = now + (want ? queue++ * stagger : 0); }\n        }\n        if (now < l.due) { busy = true; continue; }\n        if (l.x === l.goal && l.v === 0) continue;\n        // exits run on a stiffer spring and count as done once the line is fully behind the mask\n        const out = l.goal === 2, kk = out ? k * 1.8 : k, cc = out ? c * 1.3 : c;\n        for (let h = 0; h < dt; h += 1 / 120) {\n          const st = Math.min(1 / 120, dt - h);\n          l.v += (-kk * (l.x - l.goal) - cc * l.v) * st; l.x += l.v * st;\n        }\n        if ((out && l.x > 1.96) || (Math.abs(l.v) < .001 && Math.abs(l.x - l.goal) < .0005)) {\n          l.x = l.goal; l.v = 0;\n          if (l.goal === 2) { l.x = l.goal = 0; }          // through the top: park below again, unseen\n        } else busy = true;\n        paint(l, rec.cfg);\n      }\n      if (!busy && rec.done) { const d = rec.done; rec.done = null; d(); }\n    });\n    // sleep when nothing is moving; the next scroll wakes it\n    raf = busy ? requestAnimationFrame(frame) : 0;\n    if (!raf) items.forEach((rec) => { if (rec.done) { const d = rec.done; rec.done = null; d(); } });\n  }\n  const kick = () => { if (!raf && !reduced) { last = 0; raf = requestAnimationFrame(frame); } };\n  addEventListener('scroll', kick, { passive: true });\n\n  // only elements near the viewport are measured each frame\n  const io = new IntersectionObserver((es) => {\n    es.forEach((e) => { const r = items.get(e.target); if (r) r.visible = e.isIntersecting; });\n    kick();\n  }, { rootMargin: '10% 0px 10% 0px' });\n  function observe(el) { io.unobserve(el); io.observe(el); }\n\n  // manual drive: goal 1 enters, goal 2 leaves through the top; stagger by line order\n  const drive = (el, goal) => ready.then(() => new Promise((resolve) => {\n    if (!items.has(el)) build(el);\n    const rec = items.get(el); rec.cfg = cfgOf(el);\n    if (reduced) return resolve();\n    const now = performance.now();\n    rec.lines.forEach((l, i) => {\n      if (goal === 1 && l.x > 1.0005) { l.x = 0; l.v = 0; }   // interrupted mid-exit: re-enter from below\n      l.goal = goal; l.due = now + i * rec.cfg.stagger * (goal === 2 ? .6 : 1);\n    });\n    if (rec.done) rec.done();\n    rec.done = resolve; kick();\n  }));\n\n  const ready = Promise.race([document.fonts ? document.fonts.ready : Promise.resolve(), new Promise((r) => setTimeout(r, 2500))]);\n  window.VsMaskLines = {\n    refresh: (el) => { items.delete(el); return ready.then(() => build(el)); },\n    play: (el) => drive(el, 1),\n    exit: (el) => drive(el, 2),\n  };\n\n  const init = () => ready.then(() => {\n    const els = [...document.querySelectorAll('[data-vs-mask-lines]')];\n    if (reduced) { els.forEach((el) => (el.dataset.vsSplit = '1')); return; }\n    els.forEach((el) => { if (!items.has(el)) build(el); });\n    // re-measure on width change only (mobile URL-bar resizes change height, not lines)\n    let w = innerWidth, t = 0;\n    addEventListener('resize', () => {\n      clearTimeout(t);\n      t = setTimeout(() => { if (innerWidth !== w) { w = innerWidth; items.forEach((_, el) => build(el)); kick(); } }, 150);\n    });\n    kick();\n  });\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n\n<!-- usage -->\n<link href=\"https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#0f2a1f;color:#e6f2e4;padding:40vh 7vw\">\n  <h2 class=\"vs-mask-lines\" data-vs-mask-lines\n      style=\"margin:0;max-width:15ch;font:500 clamp(2.4rem,6vw,5.4rem)/1 'Inter Tight',sans-serif;letter-spacing:-.04em\">\n    The tide keeps better time than <em style=\"color:#c8f56a;font-style:normal\">any clock</em> in the harbour.\n  </h2>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "text reveal",
      "scroll",
      "mask",
      "lines",
      "spring",
      "split text",
      "editorial"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#c8f56a",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/mask-lines/index.html",
    "thumbnail": "https://vividsites.app/api/component/mask-lines/thumb.jpg",
    "video": "https://vividsites.app/api/component/mask-lines/thumb.mp4",
    "page": "https://vividsites.app/c/mask-lines"
  }
}