{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "counter-curtain",
  "type": "registry:ui",
  "title": "Counter Curtain",
  "description": "A curtain that covers the page while it loads, counting from 0 to 100 with the fonts and hero images, then lifts as the headline rises out of its masks underneath, on one clock.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/counter-curtain.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * COUNTER CURTAIN STORYBOARD\n *\n *  cover     the first paint is already the curtain. A tiny\n *            inline script decides before the page under it is\n *            drawn, so nothing flashes, even before React loads\n *  count     0 to 100, following the fonts and the hero's\n *            images. It never lands before minDuration, never\n *            waits past maxDuration, and creeps while it waits\n *            so it never looks stuck. A click, key or scroll\n *            hurries it along\n *  land      100 holds for a beat\n *  lift      the curtain slides off the page. Its counter sinks\n *            back as it goes, so the edge wipes it away instead\n *            of carrying it off\n *  rise      each line rises out of its mask as the edge\n *            uncovers it, on the same clock as the lift. Lines\n *            that sit together, like a headline, go in reading\n *            order, a beat apart\n *  settle    every animation is dropped and the hero is plain\n *            HTML again\n *\n *  The count writes straight to the DOM and everything that\n *  moves is a transform on the compositor, so the reveal stays\n *  smooth while React hydrates underneath it.\n * ───────────────────────────────────────────────────────── */\n\nconst CURTAIN = {\n  count: [0.35, 0, 0.65, 1], // the count's shape on a fast connection, as a cubic-bezier\n  speed: 3, // most of the count it covers in a second, when catching up with loading\n  crawl: 0.85, // how far the count creeps by maxDuration while assets are still loading\n  hurry: 0.3, // s left on the clock after a click, key or scroll\n  hold: 0.1, // s at 100 before the lift; the lift's slow start adds to it\n  lift: { duration: 0.9, ease: [0.6, 0, 0.15, 1] },\n  sink: 0.4, // how far the counter falls back during the lift, as a share of the curtain\n  // `lead`: s a line starts before the edge reaches its middle. `near`: how\n  // close two lines sit, in line heights, to count as one block and rise in turn.\n  rise: { duration: 1, ease: [0.16, 1, 0.3, 1], stagger: 0.08, lead: 0.06, near: 0.6 },\n} as const;\n\n// A line's resting place while the curtain is down. Keep it in step with the\n// class on CounterCurtainLine's inner span.\nconst HIDDEN = \"translateY(115%)\";\n\ntype Direction = \"up\" | \"down\" | \"left\" | \"right\";\n\ntype Plan = {\n  once: boolean;\n  key: string;\n  min: number;\n  max: number;\n  fonts: boolean;\n  images: boolean;\n  direction: Direction;\n  contained: boolean;\n  skip: boolean;\n  hidden: string;\n  t: typeof CURTAIN;\n};\n\ntype Handle = {\n  revealed: boolean;\n  on: (listener: () => void) => () => void;\n  destroy: () => void;\n};\n\ntype CurtainRoot = HTMLElement & { __counterCurtain?: Handle };\n\n/**\n * The whole sequence, from the first count to the last line settling. It runs\n * as an inline <script> while the HTML is still parsing, so the count starts on\n * the first paint, and from an effect when the component mounts on the client.\n *\n * It's serialized with toString(), so it must stay self-contained: no outer\n * variables, no imports, nothing a compiler would turn into a helper (async,\n * object spread). State lives on the root as data-counter-curtain:\n * play, then done, or skip.\n */\nfunction counterCurtain(root: HTMLElement, o: Plan): Handle {\n  const t = o.t;\n  const box = root as CurtainRoot;\n  let listeners: Array<() => void> = [];\n  let cleanups: Array<() => void> = [];\n  let anims: Animation[] = [];\n  let frame = 0;\n  let timer = 0;\n\n  const handle: Handle = {\n    revealed: false,\n    on(listener) {\n      if (handle.revealed) listener();\n      else listeners.push(listener);\n      return () => {\n        listeners = listeners.filter((l) => l !== listener);\n      };\n    },\n    destroy() {\n      cancelAnimationFrame(frame);\n      clearTimeout(timer);\n      release();\n      anims.forEach((a) => a.cancel());\n      anims = [];\n      listeners = [];\n    },\n  };\n  box.__counterCurtain = handle;\n\n  const release = () => {\n    cleanups.forEach((c) => c());\n    cleanups = [];\n  };\n  const finish = () => {\n    handle.revealed = true;\n    const due = listeners;\n    listeners = [];\n    due.forEach((l) => l());\n  };\n\n  let seen = false;\n  try {\n    seen = o.once && sessionStorage.getItem(o.key) === \"1\";\n  } catch {}\n  if (\n    o.skip ||\n    seen ||\n    typeof root.animate !== \"function\" ||\n    matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  ) {\n    root.setAttribute(\"data-counter-curtain\", \"skip\");\n    finish();\n    return handle;\n  }\n  root.setAttribute(\"data-counter-curtain\", \"play\");\n\n  // Cubic-bezier helpers: the y for an x, and the x where y reaches a value.\n  const axis = (a: number, b: number, s: number) =>\n    3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;\n  const solve = (curve: readonly number[], v: number, from: number, to: number) => {\n    let lo = 0;\n    let hi = 1;\n    for (let i = 0; i < 24; i++) {\n      const mid = (lo + hi) / 2;\n      if (axis(curve[from], curve[from + 2], mid) < v) lo = mid;\n      else hi = mid;\n    }\n    return axis(curve[to], curve[to + 2], (lo + hi) / 2);\n  };\n\n  // The page is still parsing when this first runs, so the curtain may not\n  // exist yet. Look again every frame until parsing is done.\n  let cover: HTMLElement | null = null;\n  let face: HTMLElement | null = null;\n  let values: HTMLElement[] = [];\n  let parsed = false;\n  let value = -1;\n  const find = () => {\n    if (parsed) return;\n    parsed = document.readyState !== \"loading\";\n    cover = root.querySelector(\"[data-counter-curtain-cover]\");\n    face = cover && cover.querySelector(\"[data-counter-curtain-face]\");\n    const found = cover ? Array.from(cover.querySelectorAll<HTMLElement>(\"[data-counter-curtain-value]\")) : [];\n    if (found.length !== values.length) value = -1;\n    values = found;\n  };\n\n  const listen = (target: EventTarget, type: string, fn: (event: Event) => void) => {\n    target.addEventListener(type, fn, { passive: false });\n    cleanups.push(() => target.removeEventListener(type, fn));\n  };\n\n  // Clock, in seconds of visible time: a backgrounded tab pauses the count\n  // instead of jumping it to 100 when it comes back.\n  let elapsed = 0;\n  let last = 0;\n  let end = o.min; // the count can't land before this\n  let cap = Math.max(o.max, o.min); // nor wait for loading past this\n  const max = cap; // the creep's pace, which a hurry doesn't change\n  // Someone clicking or scrolling wants in: land soon, loaded or not.\n  const hurry = () => {\n    end = Math.min(end, elapsed + t.hurry);\n    cap = Math.min(cap, elapsed + t.hurry);\n  };\n\n  if (o.contained) {\n    listen(root, \"pointerdown\", hurry);\n  } else {\n    // Lock the page without touching its styles, so nothing shifts when the\n    // scrollbar would otherwise come and go.\n    const keys = [\" \", \"PageUp\", \"PageDown\", \"Home\", \"End\", \"ArrowUp\", \"ArrowDown\", \"ArrowLeft\", \"ArrowRight\"];\n    const block = (event: Event) => {\n      hurry();\n      if (event.type !== \"keydown\" || keys.indexOf((event as KeyboardEvent).key) > -1) {\n        event.preventDefault();\n      }\n    };\n    listen(window, \"wheel\", block);\n    listen(window, \"touchmove\", block);\n    listen(window, \"keydown\", block);\n    listen(window, \"pointerdown\", hurry);\n  }\n\n  // A curtain over part of the page waits until it's on screen.\n  let started = !o.contained;\n  if (!started) {\n    const io = new IntersectionObserver(\n      (entries) => {\n        if (!entries[0].isIntersecting) return;\n        started = true;\n        io.disconnect();\n      },\n      { rootMargin: \"0px 0px -15% 0px\" },\n    );\n    io.observe(root);\n    cleanups.push(() => io.disconnect());\n  }\n\n  // Loading: the fonts, plus every image in the visible part of the hero,\n  // decoded so the first frame of the reveal doesn't stall on it.\n  let total = 0;\n  let loaded = 0;\n  let scanned = false;\n  const track = (promise: Promise<unknown>) => {\n    total++;\n    const done = () => {\n      loaded++;\n    };\n    promise.then(done, done);\n  };\n  const scan = () => {\n    scanned = true;\n    if (o.fonts && document.fonts) track(document.fonts.ready);\n    if (!o.images) return;\n    const view = o.contained\n      ? root.getBoundingClientRect()\n      : { top: 0, left: 0, bottom: innerHeight, right: innerWidth };\n    root.querySelectorAll(\"img\").forEach((img) => {\n      if (cover && cover.contains(img)) return;\n      // An image in a line is pushed down out of its mask; where it shows is the mask.\n      const r = (img.closest(\"[data-counter-curtain-line]\") || img).getBoundingClientRect();\n      if (r.bottom < view.top || r.top > view.bottom || r.right < view.left || r.left > view.right) return;\n      track(img.decode());\n    });\n  };\n\n  let goal = 0;\n  let shown = 0;\n  let marked = false; // remembered for the session, from the first counted frame\n  const tick = (now: number) => {\n    frame = requestAnimationFrame(tick);\n    find();\n    const dt = last ? Math.min(now - last, 100) / 1000 : 0;\n    last = now;\n    if (!started) return;\n    if (!marked && o.once) {\n      try {\n        sessionStorage.setItem(o.key, \"1\");\n      } catch {}\n    }\n    marked = true;\n    elapsed += dt;\n    if (parsed && !scanned) scan();\n\n    // The count may not pass the clock, so it never lands before minDuration,\n    // nor pass the loading, which creeps on its own so it never stalls.\n    const real = scanned ? (total ? loaded / total : 1) : 0;\n    const ready = (scanned && loaded >= total) || elapsed >= cap;\n    const clock = elapsed >= end ? 1 : solve(t.count, elapsed / end, 0, 1);\n    const creep = real + (1 - real) * t.crawl * (1 - Math.exp((-3 * elapsed) / max));\n    goal = Math.max(goal, Math.min(clock, ready ? 1 : creep));\n    shown = Math.min(goal, shown + dt * Math.max(t.speed, 3 / end));\n\n    const next = Math.floor(shown * 100 + 1e-6);\n    if (next !== value) {\n      value = next;\n      values.forEach((el) => {\n        const text = String(next).padStart(Number(el.getAttribute(\"data-pad\")) || 0, \"0\");\n        const node = el.firstChild;\n        if (node && node.nodeType === 3) (node as Text).data = text;\n        else el.textContent = text;\n      });\n      if (cover) cover.style.setProperty(\"--counter-progress\", String(next / 100));\n    }\n    if (next >= 100 && parsed) {\n      cancelAnimationFrame(frame);\n      timer = window.setTimeout(lift, t.hold * 1000);\n    }\n  };\n  frame = requestAnimationFrame(tick);\n\n  const lift = () => {\n    release();\n    finish();\n    if (!cover) return settle();\n    cover.style.pointerEvents = \"none\";\n\n    const vertical = o.direction === \"up\" || o.direction === \"down\";\n    const sign = o.direction === \"up\" || o.direction === \"left\" ? -1 : 1;\n    const move = (share: number) => `translate${vertical ? \"Y\" : \"X\"}(${share * 100}%)`;\n    const timing = (curve: readonly number[], duration: number, delay = 0): KeyframeAnimationOptions => ({\n      duration: duration * 1000,\n      delay: delay * 1000,\n      easing: `cubic-bezier(${curve.join(\",\")})`,\n      fill: \"both\",\n    });\n\n    anims.push(cover.animate([{ transform: \"none\" }, { transform: move(sign) }], timing(t.lift.ease, t.lift.duration)));\n    if (face) {\n      anims.push(face.animate([{ transform: \"none\" }, { transform: move(-sign * t.sink) }], timing(t.lift.ease, t.lift.duration)));\n    }\n\n    // Each line starts as the curtain's edge passes it. Lines that sit\n    // together form a block and rise in reading order, a stagger apart, once\n    // the edge reaches each of them: lines stacked in one column, like a\n    // headline, or things of a size in one row, like a nav. A photo beside\n    // the headline is its own block and rises when it's uncovered.\n    const view = o.contained\n      ? root.getBoundingClientRect()\n      : { top: 0, left: 0, width: innerWidth, height: innerHeight };\n    let previous = -Infinity; // when the line before starts\n    let before: DOMRect | null = null; // the line before\n    root.querySelectorAll<HTMLElement>(\"[data-counter-curtain-line]\").forEach((mask) => {\n      const line = mask.firstElementChild;\n      if (!line || (cover && cover.contains(mask))) return;\n      const r = mask.getBoundingClientRect();\n      const across = vertical\n        ? (r.top + r.height / 2 - view.top) / view.height\n        : (r.left + Math.min(r.width, view.width) * 0.2 - view.left) / view.width;\n      const reach = Math.min(1, Math.max(0, sign < 0 ? 1 - across : across));\n      const at = solve(t.lift.ease, reach, 1, 0) * t.lift.duration - t.rise.lead;\n      let block = false;\n      if (before) {\n        const dx = Math.max(r.left - before.right, before.left - r.right); // < 0 when they overlap\n        const dy = Math.max(r.top - before.bottom, before.top - r.bottom);\n        const small = Math.min(r.height, before.height);\n        const big = Math.max(r.height, before.height);\n        block = (dx < 0 && dy < big * t.rise.near) || (dy < 0 && big < small * 2);\n      }\n      const delay = Math.max(0, at, block ? previous + t.rise.stagger : 0);\n      previous = delay;\n      before = r;\n      anims.push(line.animate([{ transform: o.hidden }, { transform: \"none\" }], timing(t.rise.ease, t.rise.duration, delay)));\n    });\n\n    Promise.all(anims.map((a) => a.finished)).then(settle, () => {});\n  };\n\n  const settle = () => {\n    root.setAttribute(\"data-counter-curtain\", \"done\");\n    anims.forEach((a) => a.cancel());\n    anims = [];\n  };\n\n  return handle;\n}\n\nconst subscribe = () => () => {};\n\nexport interface CounterCurtainProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The hero. Mark its lines with CounterCurtainLine. */\n  children?: React.ReactNode;\n  /** Shortest the count can take, in seconds, even when everything is already loaded. */\n  minDuration?: number;\n  /** Longest it waits for loading, in seconds, before lifting anyway. */\n  maxDuration?: number;\n  /** Play once per browser session, so reloads and the back button go straight to the page. */\n  once?: boolean;\n  /** sessionStorage key that remembers it played. Give each curtain its own if there are several. */\n  storageKey?: string;\n  /** What the count waits for: web fonts, and the images in the visible part of the hero. */\n  waitFor?: Array<\"fonts\" | \"images\">;\n  /** The way the curtain leaves. */\n  direction?: Direction;\n  /** Cover this element instead of the screen, without locking the page's scroll. Starts once it's on screen. */\n  contained?: boolean;\n  /** What the curtain shows instead of the default counter. Put CounterCurtainValue where the number goes. */\n  counter?: React.ReactNode;\n  /** Called when the count reaches 100 and the curtain starts to lift, or right away when it's skipped. */\n  onComplete?: () => void;\n  /** Classes for the curtain, such as its color and font. */\n  curtainClassName?: string;\n}\n\n/**\n * A full-screen curtain that counts to 100 while the page loads, then lifts,\n * and the hero's lines rise in as it uncovers them. Reads its props once, on\n * mount; change its `key` to play it again.\n */\nexport function CounterCurtain({\n  children,\n  minDuration = 0.8,\n  maxDuration = 3,\n  once = true,\n  storageKey = \"counter-curtain\",\n  waitFor = [\"fonts\", \"images\"],\n  direction = \"up\",\n  contained = false,\n  counter,\n  onComplete,\n  curtainClassName,\n  className,\n  nonce,\n  ...props\n}: CounterCurtainProps) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  // True while rendering on the server and hydrating that HTML, where the\n  // inline script has already run. False when mounting on the client.\n  const hydrating = React.useSyncExternalStore(subscribe, () => false, () => true);\n  const [server] = React.useState(hydrating);\n\n  const plan: Plan = {\n    once,\n    key: storageKey,\n    min: minDuration,\n    max: maxDuration,\n    fonts: waitFor.includes(\"fonts\"),\n    images: waitFor.includes(\"images\"),\n    direction,\n    contained,\n    skip: false,\n    hidden: HIDDEN,\n    t: CURTAIN,\n  };\n  const planRef = React.useRef(plan);\n  const completeRef = React.useRef(onComplete);\n  React.useLayoutEffect(() => {\n    completeRef.current = onComplete;\n  });\n\n  React.useLayoutEffect(() => {\n    const root = ref.current as CurtainRoot | null;\n    if (!root) return;\n    // Server HTML without a handle means the script was blocked, by a Content\n    // Security Policy without this nonce. Skip rather than cover a page\n    // that's already been seen.\n    const handle =\n      root.__counterCurtain ?? counterCurtain(root, { ...planRef.current, skip: server });\n    const off = handle.on(() => completeRef.current?.());\n    return () => {\n      off();\n      // Strict Mode runs effects twice on a node that stays in the page.\n      queueMicrotask(() => {\n        if (!root.isConnected) handle.destroy();\n      });\n    };\n  }, [server]);\n\n  return (\n    <div\n      ref={ref}\n      suppressHydrationWarning\n      className={cn(contained && \"relative isolate\", className)}\n      {...props}\n    >\n      {server ? (\n        <script\n          nonce={nonce}\n          suppressHydrationWarning\n          dangerouslySetInnerHTML={{\n            __html: `(${counterCurtain})(document.currentScript.parentElement,${JSON.stringify(plan).replace(/</g, \"\\\\u003c\")})`,\n          }}\n        />\n      ) : null}\n      <div\n        aria-hidden\n        className={cn(\n          \"pointer-events-none inset-0 z-[100] hidden overflow-hidden in-data-[counter-curtain=play]:block\",\n          contained ? \"absolute\" : \"fixed\",\n        )}\n      >\n        <div\n          data-counter-curtain-cover=\"\"\n          suppressHydrationWarning\n          className={cn(\n            \"@container pointer-events-auto absolute inset-0 overflow-hidden bg-zinc-950 text-zinc-50 dark:bg-zinc-900\",\n            curtainClassName,\n          )}\n        >\n          <div data-counter-curtain-face=\"\" className=\"absolute inset-0\">\n            {counter ?? <DefaultCounter />}\n          </div>\n        </div>\n      </div>\n      {children}\n    </div>\n  );\n}\n\nfunction DefaultCounter() {\n  return (\n    <div className=\"flex h-full flex-col justify-end p-5 sm:p-8\">\n      <div className=\"flex items-end justify-between gap-4\">\n        <CounterCurtainValue className=\"text-[clamp(4.5rem,26cqw,15rem)] leading-[0.74] font-medium tracking-[-0.06em]\" />\n        <span className=\"pb-1 text-xs tracking-[0.2em] uppercase opacity-50\">Loading</span>\n      </div>\n      <div className=\"mt-5 h-px bg-current/15 sm:mt-7\">\n        <div className=\"h-full origin-left scale-x-[var(--counter-progress,0)] bg-current\" />\n      </div>\n    </div>\n  );\n}\n\nexport interface CounterCurtainLineProps extends React.HTMLAttributes<HTMLSpanElement> {\n  children?: React.ReactNode;\n}\n\n/**\n * A line of the hero that rises out of a clipped mask as the curtain lifts.\n * Block by default; it can hold any element, not only text.\n */\nexport function CounterCurtainLine({ className, children, ...props }: CounterCurtainLineProps) {\n  return (\n    <span\n      data-counter-curtain-line=\"\"\n      // The padding keeps descenders and accents inside the mask; the\n      // negative margin takes the room back.\n      className={cn(\"-my-[0.12em] block overflow-hidden py-[0.12em]\", className)}\n      {...props}\n    >\n      <span className=\"block in-data-[counter-curtain=play]:[transform:translateY(115%)]\">\n        {children}\n      </span>\n    </span>\n  );\n}\n\nexport interface CounterCurtainValueProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** Pad the number with zeros to this many digits, like 007. */\n  pad?: number;\n}\n\n/** The live number, for a custom `counter`. */\nexport function CounterCurtainValue({ pad = 0, className, ...props }: CounterCurtainValueProps) {\n  return (\n    <span\n      data-counter-curtain-value=\"\"\n      data-pad={pad || undefined}\n      suppressHydrationWarning\n      className={cn(\"tabular-nums\", className)}\n      {...props}\n    >\n      {\"0\".padStart(pad, \"0\")}\n    </span>\n  );\n}\n"
    }
  ]
}