{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "counter-curtain",
  "type": "registry:block",
  "title": "Counter Curtain Preloader",
  "description": "A full-screen preloader whose huge percentage fills with ink as it counts real milestones (fonts, load, your own promises), then lifts away on a spring with a leading edge that bows with its speed. Lift, drop or iris exits.",
  "docs": "Counter Curtain Preloader was added to public/vividsites/counter-curtain.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/counter-curtain",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/counter-curtain.html",
      "type": "registry:file",
      "target": "public/vividsites/counter-curtain.html",
      "content": "<!--\n  vs-counter-curtain: a full-screen preloader whose big percentage counts REAL milestones, fills\n  with ink as it counts, then lifts away like a curtain on a spring. The curtain's leading edge\n  bows with its speed (fabric lagging behind), so the exit reads as weight, not a slide.\n\n  Markup: the block below, once, as the first thing in <body>.\n    data-vs-counter-curtain-exit    = \"lift\" (default) | \"drop\" | \"iris\"\n    data-vs-counter-curtain-min     = minimum time on screen, ms            (1800)\n    data-vs-counter-curtain-timeout = failsafe: finish anyway after, ms       (9000)\n  Progress: it waits for document.fonts and window 'load' by default. Add your own milestones:\n    VsCounterCurtain.track(promise, weight = 1)   e.g. a texture, a shader compile, a fetch\n  The counter never runs ahead of the milestones, holds near 92% until the last one lands, and\n  never finishes faster than the minimum. When it has gone: 'vs-counter-curtain:done' on window,\n  and <html> gets .vs-loaded (start your entrance animations from either).\n  VsCounterCurtain.replay() shows it again (for demos).\n\n  Variables:\n    --vs-counter-curtain-bg      curtain colour                 #efe7da\n    --vs-counter-curtain-ink     counter + label colour          #1b2a4a\n    --vs-counter-curtain-empty   unfilled numeral colour         rgb(27 42 74 / .14)\n    --vs-counter-curtain-font    the counter's face              'Dancing Script', cursive\n    --vs-counter-curtain-size    counter size                    clamp(8.5rem, 36vw, 22rem)\n  prefers-reduced-motion: counts, then fades out; no lift.\n-->\n<style>\n  .vs-counter-curtain {\n    --vs-counter-curtain-bg: #efe7da;\n    --vs-counter-curtain-ink: #1b2a4a;\n    --vs-counter-curtain-empty: rgb(27 42 74 / .14);\n    --vs-counter-curtain-font: \"Dancing Script\", cursive;\n    --vs-counter-curtain-size: clamp(8.5rem, 36vw, 22rem);\n    --p: 0;\n    position: fixed; inset: 0; z-index: 1000; pointer-events: auto;\n  }\n  .vs-counter-curtain.is-gone { visibility: hidden; pointer-events: none; }\n  .vs-counter-curtain-panel {\n    position: absolute; inset: 0; background: var(--vs-counter-curtain-bg); color: var(--vs-counter-curtain-ink);\n    display: grid; place-items: center; will-change: transform;\n  }\n  /* the bowed edge: an ellipse of curtain beyond the moving edge, sized by speed */\n  .vs-counter-curtain-panel::after {\n    content: \"\"; position: absolute; left: -10%; right: -10%; height: var(--bulge, 0px);\n    background: inherit; border-radius: 0 0 50% 50% / 0 0 100% 100%; top: 100%;\n  }\n  .vs-counter-curtain[data-vs-counter-curtain-exit=\"drop\"] .vs-counter-curtain-panel::after {\n    top: auto; bottom: 100%; border-radius: 50% 50% 0 0 / 100% 100% 0 0;\n  }\n  .vs-counter-curtain-num {\n    display: inline-flex; align-items: baseline; overflow: hidden; padding: .06em .12em .14em; line-height: 1;\n    font: 700 var(--vs-counter-curtain-size)/1 var(--vs-counter-curtain-font);\n    font-variant-numeric: tabular-nums; letter-spacing: -.02em;\n  }\n  .vs-counter-curtain-fill {\n    display: inline-block; will-change: transform;\n    /* the numerals fill with ink from the bottom as the count rises */\n    background: linear-gradient(to top, var(--vs-counter-curtain-ink) calc(var(--p) * 100%), var(--vs-counter-curtain-empty) calc(var(--p) * 100%));\n    -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent;\n    padding: 0 .08em;\n  }\n  .vs-counter-curtain-pct { font-size: .32em; margin-left: .08em; opacity: .7; -webkit-text-fill-color: currentColor; }\n  .vs-counter-curtain-label {\n    position: absolute; left: clamp(16px, 3vw, 40px); bottom: clamp(20px, 4vh, 40px);\n    font: 400 11px/1 ui-monospace, \"SF Mono\", Menlo, monospace; letter-spacing: .28em; text-transform: uppercase; opacity: .55;\n  }\n  .vs-counter-curtain-line {\n    position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--vs-counter-curtain-ink);\n    transform-origin: 0 50%; transform: scaleX(var(--p)); opacity: .8; transition: opacity .3s;\n  }\n  .vs-counter-curtain.is-leaving .vs-counter-curtain-line { opacity: 0; }\n</style>\n<div class=\"vs-counter-curtain\" id=\"vs-counter-curtain\" data-vs-counter-curtain role=\"progressbar\" aria-label=\"Loading\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"0\">\n  <div class=\"vs-counter-curtain-panel\">\n    <span class=\"vs-counter-curtain-num\" aria-hidden=\"true\"><span class=\"vs-counter-curtain-fill\"><span class=\"vs-counter-curtain-digits\">0</span><span class=\"vs-counter-curtain-pct\">%</span></span></span>\n    <span class=\"vs-counter-curtain-label\" aria-hidden=\"true\">Loading</span>\n    <i class=\"vs-counter-curtain-line\" aria-hidden=\"true\"></i>\n  </div>\n</div>\n<script>\n(() => {\n  if (window.VsCounterCurtain) return;\n  const el = document.getElementById('vs-counter-curtain');\n  if (!el) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const panel = el.querySelector('.vs-counter-curtain-panel'), fill = el.querySelector('.vs-counter-curtain-fill');\n  const digits = el.querySelector('.vs-counter-curtain-digits');\n  let jobs = [], t0 = 0, raf = 0, last = 0, p = 0, state = 'count', holdAt = 0, y = 0, v = 0, fy = 0, fv = 0, failsafe = 0;\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  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('vsCounterCurtainMin', 1800), timeout = opt('vsCounterCurtainTimeout', 9000);\n    t0 = performance.now(); last = 0; p = 0; state = 'count'; y = v = fy = fv = 0;\n    el.classList.remove('is-gone', 'is-leaving'); document.documentElement.classList.remove('vs-loaded');\n    panel.style.transform = ''; panel.style.clipPath = ''; panel.style.opacity = ''; fill.style.transform = '';\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 exit = el.dataset.vsCounterCurtainExit || 'lift';\n      if (state === 'count') {\n        const r = real(), time = Math.min(1, (now - t0) / min);\n        // never ahead of the milestones, parked at .92 until the last lands, never faster than min\n        const goal = Math.min(r < 1 ? Math.min(.92, r * .92 + .04) : 1, time < 1 ? time * (2 - time) : 1);\n        p += Math.min(dt * 1.1, Math.max(0, goal - p) * (1 - Math.exp(-dt * 6)) + (goal >= 1 ? dt * .08 : 0));\n        if (goal >= 1 && p > .995) { p = 1; state = 'hold'; holdAt = now; }\n        el.style.setProperty('--p', p.toFixed(4));\n        const n = Math.round(p * 100); digits.textContent = n; el.setAttribute('aria-valuenow', n);\n      } else if (state === 'hold') {\n        if (now - holdAt > 260) { state = 'exit'; el.classList.add('is-leaving'); if (reduced) state = 'fade'; }\n      }\n      if (state === 'exit' || state === 'fade') {\n        const dir = exit === 'drop' ? 1 : -1;\n        // the numerals leave first, through the top of their own mask, then the curtain follows\n        for (let h = 0; h < dt; h += 1 / 120) {\n          const s = Math.min(1 / 120, dt - h);\n          fv += (-170 * (fy - 1) - 26 * fv) * s; fy += fv * s;\n          if (now - holdAt > 420) { v += (-90 * (y - 1) - 20 * v) * s; y += v * s; }\n        }\n        if (state === 'fade') panel.style.opacity = (1 - Math.min(1, fy)).toFixed(3);\n        else {\n          fill.style.transform = `translate3d(0,${(-fy * 110).toFixed(2)}%,0)`;\n          if (exit === 'iris') {\n            panel.style.clipPath = `circle(${(Math.max(0, 1 - y) * 75).toFixed(3)}vmax at 50% 50%)`;\n          } else {\n            panel.style.transform = `translate3d(0,${(dir * y * 100).toFixed(3)}%,0)`;\n            el.style.setProperty('--bulge', Math.min(22, Math.max(0, v) * 9).toFixed(2) + 'vh');\n          }\n        }\n        const doneMove = state === 'fade' ? fy > .99 : y > .985;\n        if (doneMove) {\n          el.classList.add('is-gone'); document.documentElement.classList.add('vs-loaded');\n          dispatchEvent(new Event('vs-counter-curtain:done')); raf = 0; return;\n        }\n        if (state === 'exit' && now - holdAt > 520 && !document.documentElement.classList.contains('vs-loaded') && y > .35) {\n          document.documentElement.classList.add('vs-loaded');           // entrances start as the curtain clears, not after\n          dispatchEvent(new Event('vs-counter-curtain:revealing'));\n        }\n      }\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n  }\n\n  if (document.fonts) track(document.fonts.ready, 1);\n  track(new Promise((r) => (document.readyState === 'complete' ? r() : addEventListener('load', r, { once: true }))), 1);\n  window.VsCounterCurtain = { track, replay: () => { jobs = jobs.map((j) => ({ ...j, done: true })); start(); } };\n  start();\n})();\n</script>\n\n<!-- usage: load the face the counter uses -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&display=swap\" rel=\"stylesheet\">\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Loaders, cursors & feedback",
    "tags": [
      "preloader",
      "loader",
      "percentage",
      "counter",
      "curtain",
      "intro",
      "page transition"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#1b2a4a",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/counter-curtain/index.html",
    "thumbnail": "https://vividsites.app/api/component/counter-curtain/thumb.jpg",
    "video": "https://vividsites.app/api/component/counter-curtain/thumb.mp4",
    "page": "https://vividsites.app/c/counter-curtain"
  }
}