# Counter Curtain

> 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.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React and Tailwind CSS.

- Docs and live preview: https://keyframeui.com/components/counter-curtain
- Tags: Preloader, Hero, Text
- npm dependencies: clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/counter-curtain.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/counter-curtain
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/counter-curtain.json
```

## Usage

The main demo shows typical usage.

### Studio Portfolio

The default counter over a studio's home page. The nav, headline, links and photo each rise as the curtain clears them. Press reload in the address bar to play it again.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/counter-curtain-demo
```

```tsx title="components/counter-curtain-demo.tsx"
"use client";

import * as React from "react";

import { CounterCurtain, CounterCurtainLine } from "@/components/ui/counter-curtain";

const photo =
  "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=1100&q=80";

// The reload button plays it again by remounting it with a new key. On a real
// site, drop `contained` and `once={false}`: it covers the screen, once per
// visit.
export function CounterCurtainDemo() {
  const [run, setRun] = React.useState(0);

  return (
    <div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-zinc-200 bg-[#f3f1ec] text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <BrowserBar url="okto.studio" run={run} onReload={() => setRun((r) => r + 1)} />
      <CounterCurtain key={run} contained once={false} className="@container min-h-0 flex-1">
        <div className="flex h-full flex-col px-5 pt-4 pb-5 sm:px-8 sm:pt-5 sm:pb-7">
          <header className="flex items-center justify-between text-sm">
            <CounterCurtainLine className="font-semibold tracking-tight">Okto°</CounterCurtainLine>
            <nav className="hidden gap-6 text-zinc-600 @md:flex dark:text-zinc-400">
              {["Work", "Studio", "Journal"].map((item) => (
                <CounterCurtainLine key={item}>{item}</CounterCurtainLine>
              ))}
            </nav>
            <CounterCurtainLine>Contact</CounterCurtainLine>
          </header>

          <div className="mt-auto flex items-end gap-8">
            <div className="min-w-0 flex-1">
              <CounterCurtainLine className="text-xs tracking-[0.18em] text-zinc-500 uppercase">
                Independent design studio, Oslo
              </CounterCurtainLine>
              <h1 className="mt-4 text-[clamp(2.75rem,10cqw,7rem)] leading-[0.92] font-semibold tracking-[-0.05em]">
                <CounterCurtainLine>Brands that</CounterCurtainLine>
                <CounterCurtainLine>move before</CounterCurtainLine>
                <CounterCurtainLine>they speak.</CounterCurtainLine>
              </h1>
              <div className="mt-6 flex flex-wrap items-end justify-between gap-x-8 gap-y-4 sm:mt-8">
                <CounterCurtainLine className="max-w-xs text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
                  Identity, websites and motion for companies that would rather be felt
                  than explained.
                </CounterCurtainLine>
                <CounterCurtainLine>
                  <a
                    href="#work"
                    className="group inline-flex items-center gap-2 text-sm font-medium underline decoration-zinc-400 underline-offset-4 transition-colors hover:decoration-current"
                  >
                    See the work
                    <span aria-hidden className="transition-transform duration-200 group-hover:translate-x-0.5">
                      →
                    </span>
                  </a>
                </CounterCurtainLine>
              </div>
            </div>
            {/* An image rises out of its frame like a line does. */}
            <CounterCurtainLine className="hidden w-[30%] shrink-0 rounded-lg @2xl:block">
              <img
                src={photo}
                alt="A low timber and glass house lit up at dusk"
                className="aspect-[4/5] w-full rounded-lg object-cover"
              />
            </CounterCurtainLine>
          </div>
        </div>
      </CounterCurtain>
    </div>
  );
}

function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
  return (
    <div className="flex h-10 shrink-0 items-center gap-3 border-b border-zinc-200 bg-white/60 px-3 dark:border-zinc-800 dark:bg-zinc-900/60">
      <div aria-hidden className="flex gap-1.5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
        ))}
      </div>
      <button
        type="button"
        onClick={onReload}
        aria-label="Reload to play it again"
        title="Play again"
        className="grid size-7 place-items-center rounded-md text-zinc-500 transition-[background-color,color,scale] duration-150 hover:bg-zinc-200/70 hover:text-zinc-900 active:scale-[0.94] dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
      >
        <svg
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
          style={{ rotate: `${run * 360}deg` }}
        >
          <path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
          <path d="M13.5 2.5v3h-3" />
        </svg>
      </button>
      <span className="mx-auto truncate rounded-md bg-zinc-100 px-3 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-800/80 dark:text-zinc-400">
        {url}
      </span>
      <span aria-hidden className="w-[4.75rem]" />
    </div>
  );
}
```

## Examples

Finished sections built with Counter Curtain. Each one installs whole, with the component included.

### Launch Page

A custom counter in the drop's colors, with a padded number and a progress bar, on a curtain that falls away. `onComplete` starts the countdown as it goes, so the clock is already ticking when it's uncovered.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/counter-curtain-launch-demo
```

```tsx title="components/counter-curtain-launch-demo.tsx"
"use client";

import * as React from "react";

import {
  CounterCurtain,
  CounterCurtainLine,
  CounterCurtainValue,
} from "@/components/ui/counter-curtain";

const photo =
  "https://images.unsplash.com/photo-1606107557195-0e29a4b5b4aa?auto=format&fit=crop&w=1000&h=1000&q=80";

const DROP = 2 * 86400 + 14 * 3600 + 9 * 60 + 31; // seconds left when the page opens

// A custom counter: the drop's own type, a padded number and a progress bar
// driven by --counter-progress. onComplete starts the clock as the curtain
// lifts, so it's already ticking when it's uncovered.
export function CounterCurtainLaunchDemo() {
  const [run, setRun] = React.useState(0);
  const [live, setLive] = React.useState(false);

  const replay = () => {
    setLive(false);
    setRun((r) => r + 1);
  };

  return (
    <div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <BrowserBar url="volt.run/drop-04" run={run} onReload={replay} />
      <CounterCurtain
        key={run}
        contained
        once={false}
        direction="down"
        onComplete={() => setLive(true)}
        curtainClassName="bg-lime-300 text-lime-950 dark:bg-lime-300"
        counter={
          <div className="flex h-full flex-col justify-between p-5 sm:p-8">
            <div className="flex justify-between text-[11px] font-semibold tracking-[0.2em] uppercase">
              <span>Volt Trainer</span>
              <span>Drop 04</span>
            </div>
            <CounterCurtainValue
              pad={3}
              className="text-center text-[clamp(5rem,30cqw,17rem)] leading-none font-black tracking-[-0.07em]"
            />
            <div className="h-1.5 overflow-hidden rounded-full bg-lime-950/10">
              <div className="h-full origin-left scale-x-[var(--counter-progress,0)] rounded-full bg-lime-950" />
            </div>
          </div>
        }
        className="@container min-h-0 flex-1"
      >
        <div className="flex h-full flex-col px-5 pt-4 pb-5 sm:px-8 sm:pt-5 sm:pb-7">
          <header className="flex items-center justify-between text-sm">
            <CounterCurtainLine className="font-black tracking-tight">VOLT</CounterCurtainLine>
            <CounterCurtainLine className="text-zinc-500">Drop 04 · 500 pairs</CounterCurtainLine>
          </header>

          <div className="flex min-h-0 flex-1 items-center gap-6 @2xl:gap-10">
            <div className="min-w-0 flex-1">
              <h1 className="text-[clamp(3.25rem,13cqw,9rem)] leading-[0.86] font-black tracking-[-0.06em] uppercase">
                <CounterCurtainLine>Volt</CounterCurtainLine>
                <CounterCurtainLine>Trainer</CounterCurtainLine>
              </h1>
              <CounterCurtainLine className="mt-5 max-w-sm text-sm leading-relaxed text-pretty text-zinc-600 sm:mt-6 dark:text-zinc-400">
                A flat, grippy sole and a heel that locks in for lifts. In lime, for one
                weekend only.
              </CounterCurtainLine>
              <div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-4 sm:mt-8">
                <CounterCurtainLine>
                  <Countdown live={live} />
                </CounterCurtainLine>
                <CounterCurtainLine>
                  <button
                    type="button"
                    className="h-10 rounded-full bg-lime-300 px-5 text-sm font-semibold text-lime-950 transition-[background-color,scale] duration-150 hover:bg-lime-200 active:scale-[0.97]"
                  >
                    Notify me
                  </button>
                </CounterCurtainLine>
              </div>
            </div>
            <CounterCurtainLine className="hidden w-[38%] shrink-0 rounded-2xl @xl:block">
              <img
                src={photo}
                alt="A lime and black trainer"
                className="aspect-square w-full rounded-2xl object-cover"
              />
            </CounterCurtainLine>
          </div>
        </div>
      </CounterCurtain>
    </div>
  );
}

function Countdown({ live }: { live: boolean }) {
  const [left, setLeft] = React.useState(DROP);

  React.useEffect(() => {
    if (!live) return;
    const timer = window.setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000);
    return () => window.clearInterval(timer);
  }, [live]);

  const parts = [
    [Math.floor(left / 86400), "d"],
    [Math.floor((left % 86400) / 3600), "h"],
    [Math.floor((left % 3600) / 60), "m"],
    [left % 60, "s"],
  ] as const;

  return (
    <p className="flex items-center gap-3 text-sm">
      <span className="relative flex size-2">
        {live ? <span className="absolute inset-0 animate-ping rounded-full bg-lime-400" /> : null}
        <span className="relative size-2 rounded-full bg-lime-500" />
      </span>
      <span className="text-zinc-500">Drops in</span>
      <span className="font-semibold tabular-nums">
        {parts.map(([value, unit]) => (
          <span key={unit} className="mr-1.5 last:mr-0">
            {String(value).padStart(2, "0")}
            <span className="text-zinc-400">{unit}</span>
          </span>
        ))}
      </span>
    </p>
  );
}

function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
  return (
    <div className="flex h-10 shrink-0 items-center gap-3 border-b border-zinc-200 bg-zinc-50 px-3 dark:border-zinc-800 dark:bg-zinc-900/60">
      <div aria-hidden className="flex gap-1.5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
        ))}
      </div>
      <button
        type="button"
        onClick={onReload}
        aria-label="Reload to play it again"
        title="Play again"
        className="grid size-7 place-items-center rounded-md text-zinc-500 transition-[background-color,color,scale] duration-150 hover:bg-zinc-200/70 hover:text-zinc-900 active:scale-[0.94] dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
      >
        <svg
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
          style={{ rotate: `${run * 360}deg` }}
        >
          <path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
          <path d="M13.5 2.5v3h-3" />
        </svg>
      </button>
      <span className="mx-auto truncate rounded-md bg-zinc-100 px-3 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-800/80 dark:text-zinc-400">
        {url}
      </span>
      <span aria-hidden className="w-[4.75rem]" />
    </div>
  );
}
```

### Editorial

The default counter set in serif on a stone curtain that slides off to the left. It waits for the three photos to load and decode, so they're sharp from the first frame.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/counter-curtain-editorial-demo
```

```tsx title="components/counter-curtain-editorial-demo.tsx"
"use client";

import * as React from "react";

import { CounterCurtain, CounterCurtainLine } from "@/components/ui/counter-curtain";

const image = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=900&h=1100&q=80`;

const rooms = [
  { id: "1618221195710-dd6b41faaea6", alt: "A long open living room with a sofa, coffee table and wide windows", caption: "The long room" },
  { id: "1586023492125-27b2c045efd7", alt: "A yellow armchair beside a floor lamp against a white wall", caption: "Reading corner" },
  { id: "1555041469-a586c61ea9bc", alt: "A green velvet sofa on a pale wooden floor", caption: "The snug" },
];

// The default counter, restyled with curtainClassName, leaving to the left.
// It waits for the three photos, so they're decoded and sharp before anything
// is uncovered.
export function CounterCurtainEditorialDemo() {
  const [run, setRun] = React.useState(0);

  return (
    <div className="flex h-[var(--demo-height,36rem)] w-full flex-col overflow-hidden rounded-xl border border-stone-200 bg-stone-50 text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-100">
      <BrowserBar url="hollow-house.journal" run={run} onReload={() => setRun((r) => r + 1)} />
      <CounterCurtain
        key={run}
        contained
        once={false}
        direction="left"
        curtainClassName="bg-stone-200 font-serif text-stone-900 dark:bg-stone-800 dark:text-stone-100"
        className="@container min-h-0 flex-1"
      >
        <div className="flex h-full flex-col px-5 pt-4 pb-5 sm:px-8 sm:pt-5 sm:pb-7">
          <header className="flex items-baseline justify-between border-b border-stone-300/70 pb-3 text-sm dark:border-stone-700/70">
            <CounterCurtainLine className="font-serif text-lg italic">Hollow House</CounterCurtainLine>
            <CounterCurtainLine className="text-xs tracking-[0.18em] text-stone-500 uppercase">
              Issue 12 · Autumn
            </CounterCurtainLine>
          </header>

          <div className="mt-6 grid min-h-0 flex-1 gap-6 @2xl:grid-cols-[1fr_1.35fr] @2xl:gap-10">
            <div className="flex flex-col justify-end">
              <h1 className="font-serif text-[clamp(2.5rem,7.5cqw,5.75rem)] leading-[0.95] tracking-[-0.03em]">
                <CounterCurtainLine>Rooms that</CounterCurtainLine>
                <CounterCurtainLine>hold the</CounterCurtainLine>
                <CounterCurtainLine className="italic">light.</CounterCurtainLine>
              </h1>
              <CounterCurtainLine className="mt-5 max-w-xs text-sm leading-relaxed text-pretty text-stone-600 dark:text-stone-400">
                A 1930s terrace in Bergen, opened up from the front door to the garden
                and furnished one chair at a time.
              </CounterCurtainLine>
            </div>
            <div className="hidden min-h-0 grid-cols-3 items-end gap-3 @2xl:grid">
              {rooms.map((room, index) => (
                <figure key={room.id} className={index === 1 ? "pb-10" : undefined}>
                  <CounterCurtainLine className="rounded-md">
                    <img
                      src={image(room.id)}
                      alt={room.alt}
                      className="aspect-[4/5] w-full rounded-md object-cover"
                    />
                  </CounterCurtainLine>
                  <CounterCurtainLine className="mt-2 font-serif text-xs text-stone-500 italic">
                    {room.caption}
                  </CounterCurtainLine>
                </figure>
              ))}
            </div>
          </div>
        </div>
      </CounterCurtain>
    </div>
  );
}

function BrowserBar({ url, run, onReload }: { url: string; run: number; onReload: () => void }) {
  return (
    <div className="flex h-10 shrink-0 items-center gap-3 border-b border-stone-200 bg-white/60 px-3 dark:border-stone-800 dark:bg-stone-900/60">
      <div aria-hidden className="flex gap-1.5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-stone-300 dark:bg-stone-700" />
        ))}
      </div>
      <button
        type="button"
        onClick={onReload}
        aria-label="Reload to play it again"
        title="Play again"
        className="grid size-7 place-items-center rounded-md text-stone-500 transition-[background-color,color,scale] duration-150 hover:bg-stone-200/70 hover:text-stone-900 active:scale-[0.94] dark:hover:bg-stone-800 dark:hover:text-stone-100"
      >
        <svg
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          className="size-3.5 transition-[rotate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
          style={{ rotate: `${run * 360}deg` }}
        >
          <path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
          <path d="M13.5 2.5v3h-3" />
        </svg>
      </button>
      <span className="mx-auto truncate rounded-md bg-stone-100 px-3 py-1 font-mono text-[11px] text-stone-500 dark:bg-stone-800/80 dark:text-stone-400">
        {url}
      </span>
      <span aria-hidden className="w-[4.75rem]" />
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` |  | The hero. Wrap each line of the headline, and anything else that should rise in, in `CounterCurtainLine`. Lines rise as the curtain's edge passes them, a beat apart, in reading order. |
| `minDuration` | `number` | `0.8` | Shortest the count can take, in seconds, even when everything has already loaded. Keep it under a second or so: it's a first impression, not a wait. |
| `maxDuration` | `number` | `3` | Longest it waits for loading, in seconds, before lifting anyway. While it waits, the count creeps on its own so it never looks stuck. A click, key press or scroll lands it within a third of a second. |
| `waitFor` | `("fonts" \| "images")[]` | `["fonts", "images"]` | What the count follows: web fonts, and the images in the visible part of the hero, decoded so the reveal doesn't stutter on them. `[]` makes it a plain timed count. |
| `once` | `boolean` | `true` | Play once per browser session. Reloads, the back button and other pages that use it go straight to the content. |
| `storageKey` | `string` | `"counter-curtain"` | The sessionStorage key that remembers it played. Curtains with the same key share it. |
| `direction` | `"up" \| "down" \| "left" \| "right"` | `"up"` | The way the curtain leaves. The lines always rise. |
| `counter` | `React.ReactNode` |  | What the curtain shows in place of the default counter. Put `<CounterCurtainValue pad={3} />` where the number goes, and use `var(--counter-progress)`, from 0 to 1, for bars, like `scale-x-[var(--counter-progress)]`. |
| `onComplete` | `() => void` |  | Called when the count reaches 100 and the curtain starts to lift, or straight away when it's skipped. Start a background video here, so it's already playing as it's uncovered. |
| `contained` | `boolean` | `false` | Cover this element instead of the screen, and leave the page's scroll alone. It starts counting once it's on screen. For a single section, or a preview like the ones here. |
| `curtainClassName` | `string` |  | Classes for the curtain, such as its color and font. |
| `className` | `string` |  | Classes for the element that wraps the hero. Other props pass through to it. |
| `nonce` | `string` |  | Your Content Security Policy's nonce, for the small inline script that puts the curtain up before the page is drawn. If the script is blocked, the curtain is skipped. |

## Source

```tsx title="components/ui/counter-curtain.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * COUNTER CURTAIN STORYBOARD
 *
 *  cover     the first paint is already the curtain. A tiny
 *            inline script decides before the page under it is
 *            drawn, so nothing flashes, even before React loads
 *  count     0 to 100, following the fonts and the hero's
 *            images. It never lands before minDuration, never
 *            waits past maxDuration, and creeps while it waits
 *            so it never looks stuck. A click, key or scroll
 *            hurries it along
 *  land      100 holds for a beat
 *  lift      the curtain slides off the page. Its counter sinks
 *            back as it goes, so the edge wipes it away instead
 *            of carrying it off
 *  rise      each line rises out of its mask as the edge
 *            uncovers it, on the same clock as the lift. Lines
 *            that sit together, like a headline, go in reading
 *            order, a beat apart
 *  settle    every animation is dropped and the hero is plain
 *            HTML again
 *
 *  The count writes straight to the DOM and everything that
 *  moves is a transform on the compositor, so the reveal stays
 *  smooth while React hydrates underneath it.
 * ───────────────────────────────────────────────────────── */

const CURTAIN = {
  count: [0.35, 0, 0.65, 1], // the count's shape on a fast connection, as a cubic-bezier
  speed: 3, // most of the count it covers in a second, when catching up with loading
  crawl: 0.85, // how far the count creeps by maxDuration while assets are still loading
  hurry: 0.3, // s left on the clock after a click, key or scroll
  hold: 0.1, // s at 100 before the lift; the lift's slow start adds to it
  lift: { duration: 0.9, ease: [0.6, 0, 0.15, 1] },
  sink: 0.4, // how far the counter falls back during the lift, as a share of the curtain
  // `lead`: s a line starts before the edge reaches its middle. `near`: how
  // close two lines sit, in line heights, to count as one block and rise in turn.
  rise: { duration: 1, ease: [0.16, 1, 0.3, 1], stagger: 0.08, lead: 0.06, near: 0.6 },
} as const;

// A line's resting place while the curtain is down. Keep it in step with the
// class on CounterCurtainLine's inner span.
const HIDDEN = "translateY(115%)";

type Direction = "up" | "down" | "left" | "right";

type Plan = {
  once: boolean;
  key: string;
  min: number;
  max: number;
  fonts: boolean;
  images: boolean;
  direction: Direction;
  contained: boolean;
  skip: boolean;
  hidden: string;
  t: typeof CURTAIN;
};

type Handle = {
  revealed: boolean;
  on: (listener: () => void) => () => void;
  destroy: () => void;
};

type CurtainRoot = HTMLElement & { __counterCurtain?: Handle };

/**
 * The whole sequence, from the first count to the last line settling. It runs
 * as an inline <script> while the HTML is still parsing, so the count starts on
 * the first paint, and from an effect when the component mounts on the client.
 *
 * It's serialized with toString(), so it must stay self-contained: no outer
 * variables, no imports, nothing a compiler would turn into a helper (async,
 * object spread). State lives on the root as data-counter-curtain:
 * play, then done, or skip.
 */
function counterCurtain(root: HTMLElement, o: Plan): Handle {
  const t = o.t;
  const box = root as CurtainRoot;
  let listeners: Array<() => void> = [];
  let cleanups: Array<() => void> = [];
  let anims: Animation[] = [];
  let frame = 0;
  let timer = 0;

  const handle: Handle = {
    revealed: false,
    on(listener) {
      if (handle.revealed) listener();
      else listeners.push(listener);
      return () => {
        listeners = listeners.filter((l) => l !== listener);
      };
    },
    destroy() {
      cancelAnimationFrame(frame);
      clearTimeout(timer);
      release();
      anims.forEach((a) => a.cancel());
      anims = [];
      listeners = [];
    },
  };
  box.__counterCurtain = handle;

  const release = () => {
    cleanups.forEach((c) => c());
    cleanups = [];
  };
  const finish = () => {
    handle.revealed = true;
    const due = listeners;
    listeners = [];
    due.forEach((l) => l());
  };

  let seen = false;
  try {
    seen = o.once && sessionStorage.getItem(o.key) === "1";
  } catch {}
  if (
    o.skip ||
    seen ||
    typeof root.animate !== "function" ||
    matchMedia("(prefers-reduced-motion: reduce)").matches
  ) {
    root.setAttribute("data-counter-curtain", "skip");
    finish();
    return handle;
  }
  root.setAttribute("data-counter-curtain", "play");

  // Cubic-bezier helpers: the y for an x, and the x where y reaches a value.
  const axis = (a: number, b: number, s: number) =>
    3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;
  const solve = (curve: readonly number[], v: number, from: number, to: number) => {
    let lo = 0;
    let hi = 1;
    for (let i = 0; i < 24; i++) {
      const mid = (lo + hi) / 2;
      if (axis(curve[from], curve[from + 2], mid) < v) lo = mid;
      else hi = mid;
    }
    return axis(curve[to], curve[to + 2], (lo + hi) / 2);
  };

  // The page is still parsing when this first runs, so the curtain may not
  // exist yet. Look again every frame until parsing is done.
  let cover: HTMLElement | null = null;
  let face: HTMLElement | null = null;
  let values: HTMLElement[] = [];
  let parsed = false;
  let value = -1;
  const find = () => {
    if (parsed) return;
    parsed = document.readyState !== "loading";
    cover = root.querySelector("[data-counter-curtain-cover]");
    face = cover && cover.querySelector("[data-counter-curtain-face]");
    const found = cover ? Array.from(cover.querySelectorAll<HTMLElement>("[data-counter-curtain-value]")) : [];
    if (found.length !== values.length) value = -1;
    values = found;
  };

  const listen = (target: EventTarget, type: string, fn: (event: Event) => void) => {
    target.addEventListener(type, fn, { passive: false });
    cleanups.push(() => target.removeEventListener(type, fn));
  };

  // Clock, in seconds of visible time: a backgrounded tab pauses the count
  // instead of jumping it to 100 when it comes back.
  let elapsed = 0;
  let last = 0;
  let end = o.min; // the count can't land before this
  let cap = Math.max(o.max, o.min); // nor wait for loading past this
  const max = cap; // the creep's pace, which a hurry doesn't change
  // Someone clicking or scrolling wants in: land soon, loaded or not.
  const hurry = () => {
    end = Math.min(end, elapsed + t.hurry);
    cap = Math.min(cap, elapsed + t.hurry);
  };

  if (o.contained) {
    listen(root, "pointerdown", hurry);
  } else {
    // Lock the page without touching its styles, so nothing shifts when the
    // scrollbar would otherwise come and go.
    const keys = [" ", "PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
    const block = (event: Event) => {
      hurry();
      if (event.type !== "keydown" || keys.indexOf((event as KeyboardEvent).key) > -1) {
        event.preventDefault();
      }
    };
    listen(window, "wheel", block);
    listen(window, "touchmove", block);
    listen(window, "keydown", block);
    listen(window, "pointerdown", hurry);
  }

  // A curtain over part of the page waits until it's on screen.
  let started = !o.contained;
  if (!started) {
    const io = new IntersectionObserver(
      (entries) => {
        if (!entries[0].isIntersecting) return;
        started = true;
        io.disconnect();
      },
      { rootMargin: "0px 0px -15% 0px" },
    );
    io.observe(root);
    cleanups.push(() => io.disconnect());
  }

  // Loading: the fonts, plus every image in the visible part of the hero,
  // decoded so the first frame of the reveal doesn't stall on it.
  let total = 0;
  let loaded = 0;
  let scanned = false;
  const track = (promise: Promise<unknown>) => {
    total++;
    const done = () => {
      loaded++;
    };
    promise.then(done, done);
  };
  const scan = () => {
    scanned = true;
    if (o.fonts && document.fonts) track(document.fonts.ready);
    if (!o.images) return;
    const view = o.contained
      ? root.getBoundingClientRect()
      : { top: 0, left: 0, bottom: innerHeight, right: innerWidth };
    root.querySelectorAll("img").forEach((img) => {
      if (cover && cover.contains(img)) return;
      // An image in a line is pushed down out of its mask; where it shows is the mask.
      const r = (img.closest("[data-counter-curtain-line]") || img).getBoundingClientRect();
      if (r.bottom < view.top || r.top > view.bottom || r.right < view.left || r.left > view.right) return;
      track(img.decode());
    });
  };

  let goal = 0;
  let shown = 0;
  let marked = false; // remembered for the session, from the first counted frame
  const tick = (now: number) => {
    frame = requestAnimationFrame(tick);
    find();
    const dt = last ? Math.min(now - last, 100) / 1000 : 0;
    last = now;
    if (!started) return;
    if (!marked && o.once) {
      try {
        sessionStorage.setItem(o.key, "1");
      } catch {}
    }
    marked = true;
    elapsed += dt;
    if (parsed && !scanned) scan();

    // The count may not pass the clock, so it never lands before minDuration,
    // nor pass the loading, which creeps on its own so it never stalls.
    const real = scanned ? (total ? loaded / total : 1) : 0;
    const ready = (scanned && loaded >= total) || elapsed >= cap;
    const clock = elapsed >= end ? 1 : solve(t.count, elapsed / end, 0, 1);
    const creep = real + (1 - real) * t.crawl * (1 - Math.exp((-3 * elapsed) / max));
    goal = Math.max(goal, Math.min(clock, ready ? 1 : creep));
    shown = Math.min(goal, shown + dt * Math.max(t.speed, 3 / end));

    const next = Math.floor(shown * 100 + 1e-6);
    if (next !== value) {
      value = next;
      values.forEach((el) => {
        const text = String(next).padStart(Number(el.getAttribute("data-pad")) || 0, "0");
        const node = el.firstChild;
        if (node && node.nodeType === 3) (node as Text).data = text;
        else el.textContent = text;
      });
      if (cover) cover.style.setProperty("--counter-progress", String(next / 100));
    }
    if (next >= 100 && parsed) {
      cancelAnimationFrame(frame);
      timer = window.setTimeout(lift, t.hold * 1000);
    }
  };
  frame = requestAnimationFrame(tick);

  const lift = () => {
    release();
    finish();
    if (!cover) return settle();
    cover.style.pointerEvents = "none";

    const vertical = o.direction === "up" || o.direction === "down";
    const sign = o.direction === "up" || o.direction === "left" ? -1 : 1;
    const move = (share: number) => `translate${vertical ? "Y" : "X"}(${share * 100}%)`;
    const timing = (curve: readonly number[], duration: number, delay = 0): KeyframeAnimationOptions => ({
      duration: duration * 1000,
      delay: delay * 1000,
      easing: `cubic-bezier(${curve.join(",")})`,
      fill: "both",
    });

    anims.push(cover.animate([{ transform: "none" }, { transform: move(sign) }], timing(t.lift.ease, t.lift.duration)));
    if (face) {
      anims.push(face.animate([{ transform: "none" }, { transform: move(-sign * t.sink) }], timing(t.lift.ease, t.lift.duration)));
    }

    // Each line starts as the curtain's edge passes it. Lines that sit
    // together form a block and rise in reading order, a stagger apart, once
    // the edge reaches each of them: lines stacked in one column, like a
    // headline, or things of a size in one row, like a nav. A photo beside
    // the headline is its own block and rises when it's uncovered.
    const view = o.contained
      ? root.getBoundingClientRect()
      : { top: 0, left: 0, width: innerWidth, height: innerHeight };
    let previous = -Infinity; // when the line before starts
    let before: DOMRect | null = null; // the line before
    root.querySelectorAll<HTMLElement>("[data-counter-curtain-line]").forEach((mask) => {
      const line = mask.firstElementChild;
      if (!line || (cover && cover.contains(mask))) return;
      const r = mask.getBoundingClientRect();
      const across = vertical
        ? (r.top + r.height / 2 - view.top) / view.height
        : (r.left + Math.min(r.width, view.width) * 0.2 - view.left) / view.width;
      const reach = Math.min(1, Math.max(0, sign < 0 ? 1 - across : across));
      const at = solve(t.lift.ease, reach, 1, 0) * t.lift.duration - t.rise.lead;
      let block = false;
      if (before) {
        const dx = Math.max(r.left - before.right, before.left - r.right); // < 0 when they overlap
        const dy = Math.max(r.top - before.bottom, before.top - r.bottom);
        const small = Math.min(r.height, before.height);
        const big = Math.max(r.height, before.height);
        block = (dx < 0 && dy < big * t.rise.near) || (dy < 0 && big < small * 2);
      }
      const delay = Math.max(0, at, block ? previous + t.rise.stagger : 0);
      previous = delay;
      before = r;
      anims.push(line.animate([{ transform: o.hidden }, { transform: "none" }], timing(t.rise.ease, t.rise.duration, delay)));
    });

    Promise.all(anims.map((a) => a.finished)).then(settle, () => {});
  };

  const settle = () => {
    root.setAttribute("data-counter-curtain", "done");
    anims.forEach((a) => a.cancel());
    anims = [];
  };

  return handle;
}

const subscribe = () => () => {};

export interface CounterCurtainProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The hero. Mark its lines with CounterCurtainLine. */
  children?: React.ReactNode;
  /** Shortest the count can take, in seconds, even when everything is already loaded. */
  minDuration?: number;
  /** Longest it waits for loading, in seconds, before lifting anyway. */
  maxDuration?: number;
  /** Play once per browser session, so reloads and the back button go straight to the page. */
  once?: boolean;
  /** sessionStorage key that remembers it played. Give each curtain its own if there are several. */
  storageKey?: string;
  /** What the count waits for: web fonts, and the images in the visible part of the hero. */
  waitFor?: Array<"fonts" | "images">;
  /** The way the curtain leaves. */
  direction?: Direction;
  /** Cover this element instead of the screen, without locking the page's scroll. Starts once it's on screen. */
  contained?: boolean;
  /** What the curtain shows instead of the default counter. Put CounterCurtainValue where the number goes. */
  counter?: React.ReactNode;
  /** Called when the count reaches 100 and the curtain starts to lift, or right away when it's skipped. */
  onComplete?: () => void;
  /** Classes for the curtain, such as its color and font. */
  curtainClassName?: string;
}

/**
 * A full-screen curtain that counts to 100 while the page loads, then lifts,
 * and the hero's lines rise in as it uncovers them. Reads its props once, on
 * mount; change its `key` to play it again.
 */
export function CounterCurtain({
  children,
  minDuration = 0.8,
  maxDuration = 3,
  once = true,
  storageKey = "counter-curtain",
  waitFor = ["fonts", "images"],
  direction = "up",
  contained = false,
  counter,
  onComplete,
  curtainClassName,
  className,
  nonce,
  ...props
}: CounterCurtainProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  // True while rendering on the server and hydrating that HTML, where the
  // inline script has already run. False when mounting on the client.
  const hydrating = React.useSyncExternalStore(subscribe, () => false, () => true);
  const [server] = React.useState(hydrating);

  const plan: Plan = {
    once,
    key: storageKey,
    min: minDuration,
    max: maxDuration,
    fonts: waitFor.includes("fonts"),
    images: waitFor.includes("images"),
    direction,
    contained,
    skip: false,
    hidden: HIDDEN,
    t: CURTAIN,
  };
  const planRef = React.useRef(plan);
  const completeRef = React.useRef(onComplete);
  React.useLayoutEffect(() => {
    completeRef.current = onComplete;
  });

  React.useLayoutEffect(() => {
    const root = ref.current as CurtainRoot | null;
    if (!root) return;
    // Server HTML without a handle means the script was blocked, by a Content
    // Security Policy without this nonce. Skip rather than cover a page
    // that's already been seen.
    const handle =
      root.__counterCurtain ?? counterCurtain(root, { ...planRef.current, skip: server });
    const off = handle.on(() => completeRef.current?.());
    return () => {
      off();
      // Strict Mode runs effects twice on a node that stays in the page.
      queueMicrotask(() => {
        if (!root.isConnected) handle.destroy();
      });
    };
  }, [server]);

  return (
    <div
      ref={ref}
      suppressHydrationWarning
      className={cn(contained && "relative isolate", className)}
      {...props}
    >
      {server ? (
        <script
          nonce={nonce}
          suppressHydrationWarning
          dangerouslySetInnerHTML={{
            __html: `(${counterCurtain})(document.currentScript.parentElement,${JSON.stringify(plan).replace(/</g, "\\u003c")})`,
          }}
        />
      ) : null}
      <div
        aria-hidden
        className={cn(
          "pointer-events-none inset-0 z-[100] hidden overflow-hidden in-data-[counter-curtain=play]:block",
          contained ? "absolute" : "fixed",
        )}
      >
        <div
          data-counter-curtain-cover=""
          suppressHydrationWarning
          className={cn(
            "@container pointer-events-auto absolute inset-0 overflow-hidden bg-zinc-950 text-zinc-50 dark:bg-zinc-900",
            curtainClassName,
          )}
        >
          <div data-counter-curtain-face="" className="absolute inset-0">
            {counter ?? <DefaultCounter />}
          </div>
        </div>
      </div>
      {children}
    </div>
  );
}

function DefaultCounter() {
  return (
    <div className="flex h-full flex-col justify-end p-5 sm:p-8">
      <div className="flex items-end justify-between gap-4">
        <CounterCurtainValue className="text-[clamp(4.5rem,26cqw,15rem)] leading-[0.74] font-medium tracking-[-0.06em]" />
        <span className="pb-1 text-xs tracking-[0.2em] uppercase opacity-50">Loading</span>
      </div>
      <div className="mt-5 h-px bg-current/15 sm:mt-7">
        <div className="h-full origin-left scale-x-[var(--counter-progress,0)] bg-current" />
      </div>
    </div>
  );
}

export interface CounterCurtainLineProps extends React.HTMLAttributes<HTMLSpanElement> {
  children?: React.ReactNode;
}

/**
 * A line of the hero that rises out of a clipped mask as the curtain lifts.
 * Block by default; it can hold any element, not only text.
 */
export function CounterCurtainLine({ className, children, ...props }: CounterCurtainLineProps) {
  return (
    <span
      data-counter-curtain-line=""
      // The padding keeps descenders and accents inside the mask; the
      // negative margin takes the room back.
      className={cn("-my-[0.12em] block overflow-hidden py-[0.12em]", className)}
      {...props}
    >
      <span className="block in-data-[counter-curtain=play]:[transform:translateY(115%)]">
        {children}
      </span>
    </span>
  );
}

export interface CounterCurtainValueProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Pad the number with zeros to this many digits, like 007. */
  pad?: number;
}

/** The live number, for a custom `counter`. */
export function CounterCurtainValue({ pad = 0, className, ...props }: CounterCurtainValueProps) {
  return (
    <span
      data-counter-curtain-value=""
      data-pad={pad || undefined}
      suppressHydrationWarning
      className={cn("tabular-nums", className)}
      {...props}
    >
      {"0".padStart(pad, "0")}
    </span>
  );
}
```
