{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink-preloader",
  "type": "registry:block",
  "title": "Ink Preloader",
  "description": "A preloader where ink rises inside your wordmark as the page really loads: the level is a spring, so each milestone surges and sloshes, and the edge feathers like ink soaking into paper. At 100% the ink floods the screen from the word and the sheet lifts away.",
  "docs": "Ink Preloader was added to public/vividsites/ink-preloader.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/ink-preloader",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/ink-preloader.html",
      "type": "registry:file",
      "target": "public/vividsites/ink-preloader.html",
      "content": "<!--\n  vs-ink-preloader: a full-screen preloader where ink rises inside your wordmark as the page loads.\n  The ink level is a spring, so every milestone lands as a surge that sloshes and settles; the\n  surface is two layered waves whose height grows with the surge, and a turbulence displacement\n  feathers the edge like ink soaking into paper. At 100% the ink floods the screen from the word,\n  then the whole sheet lifts away.\n\n  Markup: the block below, once, as the first thing in <body>. Change the word inside BOTH <text>\n  elements (the SVG re-fits itself to any word once the font has loaded).\n    data-vs-ink-preloader-exit    = \"flood\" (default) | \"fade\"\n    data-vs-ink-preloader-min     = minimum time on screen, ms            (2000)\n    data-vs-ink-preloader-timeout = failsafe: finish anyway after, ms       (9000)\n  Progress waits for document.fonts and window 'load'. Add your own milestones:\n    VsInkPreloader.track(promise, weight = 1)\n  Events: 'vs-ink-preloader:done' on window; <html> gets .vs-loaded as the sheet starts to clear.\n  VsInkPreloader.replay() runs it again (for demos).\n\n  Variables:\n    --vs-ink-preloader-bg     the paper                     #f1eee6\n    --vs-ink-preloader-ink    the ink                       #141312\n    --vs-ink-preloader-ghost  the empty letters' outline    color-mix(ink 22%)\n    --vs-ink-preloader-font   the wordmark face             'Oswald', sans-serif (700, uppercase)\n    --vs-ink-preloader-wave   wave height, 0–2              1\n    --vs-ink-preloader-width  wordmark width                min(86vw, 1100px)\n  prefers-reduced-motion: the ink rises flat with no slosh, then the sheet fades.\n-->\n<style>\n  .vs-ink-preloader {\n    --vs-ink-preloader-bg: #f1eee6;\n    --vs-ink-preloader-ink: #141312;\n    --vs-ink-preloader-ghost: color-mix(in srgb, var(--vs-ink-preloader-ink) 22%, transparent);\n    --vs-ink-preloader-font: \"Oswald\", sans-serif;\n    --vs-ink-preloader-wave: 1;\n    --vs-ink-preloader-width: min(86vw, 1100px);\n    position: fixed; inset: 0; z-index: 1000; background: var(--vs-ink-preloader-bg); color: var(--vs-ink-preloader-ink);\n    display: grid; place-items: center; overflow: hidden; will-change: transform, opacity;\n  }\n  .vs-ink-preloader.is-gone { visibility: hidden; pointer-events: none; }\n  .vs-ink-preloader-svg { width: var(--vs-ink-preloader-width); height: auto; overflow: visible; display: block; }\n  .vs-ink-preloader-word { font: 700 200px/1 var(--vs-ink-preloader-font); text-transform: uppercase; letter-spacing: -.01em; }\n  .vs-ink-preloader-ghost { fill: none; stroke: var(--vs-ink-preloader-ghost); stroke-width: 1.4; vector-effect: non-scaling-stroke; }\n  .vs-ink-preloader-back { fill: var(--vs-ink-preloader-ink); opacity: .32; }\n  .vs-ink-preloader-front { fill: var(--vs-ink-preloader-ink); }\n  .vs-ink-preloader-sheen { fill: none; stroke: color-mix(in srgb, var(--vs-ink-preloader-bg) 55%, transparent); stroke-width: 1.2; vector-effect: non-scaling-stroke; opacity: .6; }\n  .vs-ink-preloader-flood { position: absolute; inset: 0; width: 100%; height: 100%; fill: var(--vs-ink-preloader-ink); pointer-events: none; overflow: visible; }\n  @media (max-width: 600px) { .vs-ink-preloader { --vs-ink-preloader-width: 92vw; } }\n  .vs-ink-preloader-pct {\n    position: absolute; right: clamp(16px, 3vw, 40px); bottom: clamp(18px, 4vh, 40px);\n    font: 400 12px/1 ui-monospace, \"SF Mono\", Menlo, monospace; letter-spacing: .2em; font-variant-numeric: tabular-nums; opacity: .6;\n  }\n</style>\n<div class=\"vs-ink-preloader\" id=\"vs-ink-preloader\" role=\"progressbar\" aria-label=\"Loading\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"0\">\n  <svg class=\"vs-ink-preloader-svg\" viewBox=\"0 0 1000 220\" aria-hidden=\"true\">\n    <defs>\n      <clipPath id=\"vs-ink-preloader-clip\"><text class=\"vs-ink-preloader-word\" x=\"500\" y=\"190\" text-anchor=\"middle\">Estuary</text></clipPath>\n      <filter id=\"vs-ink-preloader-bleed\" x=\"-5%\" y=\"-20%\" width=\"110%\" height=\"140%\">\n        <feTurbulence type=\"fractalNoise\" baseFrequency=\".022 .06\" numOctaves=\"2\" seed=\"7\" result=\"n\"/>\n        <feDisplacementMap in=\"SourceGraphic\" in2=\"n\" scale=\"9\" xChannelSelector=\"R\" yChannelSelector=\"G\"/>\n      </filter>\n    </defs>\n    <text class=\"vs-ink-preloader-word vs-ink-preloader-ghost\" x=\"500\" y=\"190\" text-anchor=\"middle\">Estuary</text>\n    <g clip-path=\"url(#vs-ink-preloader-clip)\">\n      <g filter=\"url(#vs-ink-preloader-bleed)\">\n        <path class=\"vs-ink-preloader-back\"/>\n        <path class=\"vs-ink-preloader-front\"/>\n        <path class=\"vs-ink-preloader-sheen\"/>\n      </g>\n    </g>\n  </svg>\n  <svg class=\"vs-ink-preloader-flood\" aria-hidden=\"true\">\n    <filter id=\"vs-ink-preloader-splash\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n      <feTurbulence type=\"fractalNoise\" baseFrequency=\".011\" numOctaves=\"3\" seed=\"3\" result=\"n\"/>\n      <feDisplacementMap in=\"SourceGraphic\" in2=\"n\" scale=\"70\" xChannelSelector=\"R\" yChannelSelector=\"G\"/>\n    </filter>\n    <circle r=\"0\" filter=\"url(#vs-ink-preloader-splash)\"/>\n  </svg>\n  <span class=\"vs-ink-preloader-pct\" aria-hidden=\"true\">000</span>\n</div>\n<script>\n(() => {\n  if (window.VsInkPreloader) return;\n  const el = document.getElementById('vs-ink-preloader');\n  if (!el) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const svg = el.querySelector('svg'), ghost = el.querySelector('.vs-ink-preloader-ghost');\n  const back = el.querySelector('.vs-ink-preloader-back'), front = el.querySelector('.vs-ink-preloader-front'), sheen = el.querySelector('.vs-ink-preloader-sheen');\n  const flood = el.querySelector('.vs-ink-preloader-flood circle'), pct = el.querySelector('.vs-ink-preloader-pct');\n  let box = { x: 0, y: 30, width: 1000, height: 160 };\n  let jobs = [], raf = 0, t0 = 0, last = 0, failsafe = 0, state = 'count', holdAt = 0;\n  let p = 0, L = 0, Lv = 0, amp = 0, ph = 0, f = 0, fv = 0, u = 0, uv = 0;\n\n  // fit the viewBox to the word's real ink box, whatever the word and face\n  const fit = () => {\n    try {\n      const b = ghost.getBBox(); if (!(b.width > 0)) return;\n      // the letters' real ink box (cap height to baseline), not the font's em box\n      const cs = getComputedStyle(ghost), c = fit.c || (fit.c = document.createElement('canvas').getContext('2d'));\n      c.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;\n      const txt = cs.textTransform === 'uppercase' ? ghost.textContent.toUpperCase() : ghost.textContent;\n      const m = c.measureText(txt), base = +ghost.getAttribute('y');\n      const asc = m.actualBoundingBoxAscent || b.height * .55, desc = Math.max(0, m.actualBoundingBoxDescent || 0);\n      box = { x: b.x, y: base - asc, width: b.width, height: asc + desc };\n    } catch (e) {}\n    const pad = box.height * .08;\n    svg.setAttribute('viewBox', `${(box.x - pad).toFixed(1)} ${(box.y - pad).toFixed(1)} ${(box.width + pad * 2).toFixed(1)} ${(box.height + pad * 2).toFixed(1)}`);\n  };\n  const track = (promise, weight = 1) => {\n    const j = { w: weight, done: false }; jobs.push(j);\n    Promise.resolve(promise).catch(() => {}).then(() => { j.done = true; });\n    return promise;\n  };\n  const real = () => { const W = jobs.reduce((s, j) => s + j.w, 0); return W ? jobs.reduce((s, j) => s + (j.done ? j.w : 0), 0) / W : 1; };\n\n  // two layered sine surfaces across the word's box\n  const wave = (level, a, phase, k) => {\n    const x0 = box.x - 20, x1 = box.x + box.width + 20, n = 36, H = box.height;\n    const top = box.y + H + a * 1.2 - level * (H + a * 3.2);\n    let d = '';\n    for (let i = 0; i <= n; i++) {\n      const x = x0 + (x1 - x0) * i / n, t = (x - box.x) / box.width;\n      const y = top + a * (Math.sin(t * 6.3 * k + phase) * .62 + Math.sin(t * 13.1 * k - phase * 1.37) * .38);\n      d += (i ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);\n    }\n    return d;\n  };\n\n  function start() {\n    cancelAnimationFrame(raf);\n    const opt = (k, fb) => { const v = parseFloat(el.dataset[k]); return isNaN(v) ? fb : v; };\n    const min = opt('vsInkPreloaderMin', 2000), timeout = opt('vsInkPreloaderTimeout', 9000);\n    t0 = performance.now(); last = 0; state = 'count'; p = L = Lv = amp = f = fv = u = uv = 0;\n    el.classList.remove('is-gone'); document.documentElement.classList.remove('vs-loaded');\n    el.style.transform = ''; el.style.opacity = ''; flood.setAttribute('r', 0);\n    clearTimeout(failsafe); failsafe = setTimeout(() => jobs.forEach((j) => (j.done = true)), timeout);\n    const tick = (now) => {\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      const W = parseFloat(getComputedStyle(el).getPropertyValue('--vs-ink-preloader-wave')) || 0;\n      if (state === 'count') {\n        const r = real(), time = Math.min(1, (now - t0) / min);\n        // the target never runs ahead of the milestones and parks near 90% until the last one lands\n        const goal = Math.min(r < 1 ? Math.min(.9, r * .88 + .05) : 1, time < 1 ? time * (2 - time) : 1);\n        p += Math.min(Math.max(0, goal - p), Math.max(0, goal - p) * (1 - Math.exp(-dt * 3.2)) + (goal >= 1 ? dt * .35 : 0));\n        if (goal >= 1 && p > .998) p = 1;\n      }\n      // the ink level is a spring on p: surges overshoot a little and slosh\n      for (let h = 0; h < dt; h += 1 / 120) {\n        const s = Math.min(1 / 120, dt - h);\n        if (reduced) { L = p; Lv = 0; } else { Lv += (70 * (p - L) - 9 * Lv) * s; L += Lv * s; }\n      }\n      const H = box.height;\n      const goalAmp = reduced ? 0 : H * .018 * W * (1 + Math.min(4, Math.abs(Lv) * 9));\n      amp += (goalAmp - amp) * (1 - Math.exp(-dt * 5));\n      ph += dt * (2.2 + Math.abs(Lv) * 10);\n      const lv = Math.min(1.02, Math.max(0, L));\n      back.setAttribute('d', wave(lv + .012, amp * 1.25, ph * .8 + 2.1, .8) + `L${box.x + box.width + 20} ${box.y + H + 40}L${box.x - 20} ${box.y + H + 40}Z`);\n      const fd = wave(lv, amp, ph, 1);\n      front.setAttribute('d', fd + `L${box.x + box.width + 20} ${box.y + H + 40}L${box.x - 20} ${box.y + H + 40}Z`);\n      sheen.setAttribute('d', fd);\n      const n = Math.round(p * 100); pct.textContent = String(n).padStart(3, '0'); el.setAttribute('aria-valuenow', n);\n\n      if (state === 'count' && p === 1 && L > .97 && Math.abs(Lv) < .4) { state = 'hold'; holdAt = now; }\n      else if (state === 'hold' && now - holdAt > 240) state = 'exit';\n      if (state === 'exit') {\n        const exit = reduced ? 'fade' : el.dataset.vsInkPreloaderExit || 'flood';\n        for (let h = 0; h < dt; h += 1 / 120) {\n          const s = Math.min(1 / 120, dt - h);\n          fv += (-60 * (f - 1) - 15.5 * fv) * s; f += fv * s;            // flood radius\n          if (exit === 'fade' || f > .96) { uv += (-80 * (u - 1) - 18 * uv) * s; u += uv * s; }   // lift / fade\n        }\n        if (exit === 'fade') el.style.opacity = (1 - Math.min(1, u)).toFixed(3);\n        else {\n          const r = svg.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;\n          flood.setAttribute('cx', cx.toFixed(0)); flood.setAttribute('cy', cy.toFixed(0));\n          flood.setAttribute('r', (Math.max(0, f) * (Math.hypot(innerWidth, innerHeight) * .62 + 80)).toFixed(1));\n          el.style.transform = `translate3d(0,${(-Math.max(0, u) * 102).toFixed(3)}%,0)`;\n        }\n        if (u > .3 && !document.documentElement.classList.contains('vs-loaded')) document.documentElement.classList.add('vs-loaded');\n        if (u > .992) {\n          el.classList.add('is-gone'); document.documentElement.classList.add('vs-loaded');\n          dispatchEvent(new Event('vs-ink-preloader:done')); raf = 0; return;\n        }\n      }\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n  }\n\n  if (document.fonts) { track(document.fonts.ready.then(fit), 1); document.fonts.addEventListener && document.fonts.addEventListener('loadingdone', fit); }\n  track(new Promise((r) => (document.readyState === 'complete' ? r() : addEventListener('load', r, { once: true }))), 1);\n  fit();\n  window.VsInkPreloader = { track, fit, replay: () => { jobs = jobs.map((j) => ({ ...j, done: true })); fit(); start(); } };\n  start();\n})();\n</script>\n\n<!-- usage: load the wordmark face -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Oswald:wght@700&display=swap\" rel=\"stylesheet\">\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Loaders, cursors & feedback",
    "tags": [
      "preloader",
      "loader",
      "wordmark",
      "ink",
      "liquid",
      "fill",
      "intro",
      "svg"
    ],
    "tech": [
      "SVG",
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Oswald:wght@700&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#141312",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/ink-preloader/index.html",
    "thumbnail": "https://vividsites.app/api/component/ink-preloader/thumb.jpg",
    "video": "https://vividsites.app/api/component/ink-preloader/thumb.mp4",
    "page": "https://vividsites.app/c/ink-preloader"
  }
}