Skip to main content

ns-ui / loud

Curtain Leader Countdown

A full-bleed route curtain modeled on the SMPTE Universal Leader film countdown, compressed to preview-card scale and stated as a departure, not a fidelity claim.

Usage

registry/loud/curtain-leader-countdown/demo.tsx
"use client";

import { CurtainLeaderCountdown } from "./component";

// Display-only autoplay loop — the countdown-and-flash cycle is the
// unconditional idle animation from mount, so the demo needs nothing beyond
// mounting the curtain itself.
export default function CurtainLeaderCountdownDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <CurtainLeaderCountdown />
    </main>
  );
}

Component

registry/loud/curtain-leader-countdown/component.tsx
"use client";

import { useEffect, useId, useRef } from "react";

// ---------------------------------------------------------------------------
// CurtainLeaderCountdown — a full-bleed route curtain modeled on the SMPTE
// Universal Leader film countdown (1966): a rotating clock-arm sweeping a
// circular target once per digit, numerals ticking 8->2, C/C/F/F control-
// frame glyphs flashing only on the "4" count, and a black "PICTURE START"
// beat before the loop restarts.
//
// COMPRESSED, NOT LITERAL. The real leader runs one numeral per real second
// (~9s total). Preview-card scale can't carry that, so this compresses to
// 450ms/digit (7 steps, 8->2, ~3.15s) plus a 350ms black beat, ~3.5s/cycle.
// The clock-arm still does one full revolution per digit, just tied to the
// (much faster) digit interval rather than a real second — a stated
// departure, not a fidelity claim.
//
// THE 2-POP HAS NO VISUAL EQUIVALENT IN THE SOURCE. The leader's "2-pop" is
// an audio cue with nothing to look at. Here it is translated as a one-frame
// flash of the target ring to full --foreground plus a brief scale-pulse,
// landing right before the cut to black — an invented stand-in, not a
// historical detail, and it goes to full VALUE only, never --ns-accent: that
// is the standing pointer/beam-highlight rule generalized to a scripted
// flash, and sampling its R/G/B in either theme must land on equal channels.
//
// Every phase of the loop is a presentable frame on purpose, because the
// no-autoplay verifier can land on any of them: the ring outline is drawn
// every frame regardless of phase, the outgoing digit ghosts into the
// incoming one in --ns-muted instead of hard-cutting, and the black beat
// still shows the ring plus a muted ghost of the "2" target rather than a
// truly empty frame.
// ---------------------------------------------------------------------------

export interface CurtainLeaderCountdownProps {
  /** Called once per completed cycle, right as the black beat ends. */
  onComplete?: () => void;
  /** Restart automatically after each cycle. @default true */
  loop?: boolean;
  /** Freeze the loop on its current frame without unmounting. */
  paused?: boolean;
  className?: string;
  style?: React.CSSProperties;
}

const DIGIT_MS = 450; // compressed from the source's 1 numeral/real second
const STEPS = 7; // 8,7,6,5,4,3,2
const COUNTDOWN_MS = DIGIT_MS * STEPS; // 3150
const BEAT_MS = 350; // black "PICTURE START" beat before the loop restarts
const CYCLE_MS = COUNTDOWN_MS + BEAT_MS; // 3500
const FIRST_DIGIT = 8;
const LAST_DIGIT = 2;
const CONTROL_DIGIT = 4; // C/C/F/F only render on this step, as in the source
const GHOST_FRAC = 0.22; // fraction of a digit step the outgoing ghost fades across
const FLASH_FRAC = 0.82; // fraction into the "2" step the 2-pop stand-in begins
const ANNOUNCE_MIN_MS = 1400; // sr-only throttle, well below the 450ms tick

// The frozen reduced-motion frame: a quarter-turn into the "4" step's sweep
// — control frames visible, numeral centered, arm at a clean quadrant angle.
// The single most structured frame in the loop, deliberately not t=0.
const STATIC_MS = (FIRST_DIGIT - CONTROL_DIGIT) * DIGIT_MS + DIGIT_MS * 0.25;

interface Frame {
  phase: "countdown" | "beat";
  digit: number;
  ghostDigit: number | null;
  ghostAlpha: number;
  angle: number;
  showControls: boolean;
  flashAmt: number;
}

function computeFrame(msIntoCycle: number): Frame {
  if (msIntoCycle < COUNTDOWN_MS) {
    const idx = Math.min(STEPS - 1, Math.floor(msIntoCycle / DIGIT_MS));
    const localFrac = (msIntoCycle - idx * DIGIT_MS) / DIGIT_MS;
    const digit = FIRST_DIGIT - idx;
    const ghostDigit = idx > 0 ? digit + 1 : null;
    const ghostAlpha =
      ghostDigit !== null ? Math.max(0, 1 - localFrac / GHOST_FRAC) : 0;
    const flashAmt =
      digit === LAST_DIGIT && localFrac >= FLASH_FRAC
        ? (localFrac - FLASH_FRAC) / (1 - FLASH_FRAC)
        : 0;
    return {
      phase: "countdown",
      digit,
      ghostDigit,
      ghostAlpha,
      angle: localFrac * Math.PI * 2,
      showControls: digit === CONTROL_DIGIT,
      flashAmt,
    };
  }
  // the black beat: never a genuinely empty frame — the ring stays on
  // screen and a muted ghost of the "2" target holds through the whole beat
  const beatFrac = (msIntoCycle - COUNTDOWN_MS) / BEAT_MS;
  return {
    phase: "beat",
    digit: LAST_DIGIT,
    ghostDigit: LAST_DIGIT,
    ghostAlpha: 0.18 + 0.22 * (1 - beatFrac),
    angle: Math.PI * 2,
    showControls: false,
    flashAmt: 0,
  };
}

export function CurtainLeaderCountdown({
  onComplete,
  loop = true,
  paused,
  className = "",
  style,
}: CurtainLeaderCountdownProps) {
  const uid = useId();
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const statusRef = useRef<HTMLDivElement>(null);
  const liveRef = useRef<HTMLSpanElement>(null);
  const pausedRef = useRef(paused);
  pausedRef.current = paused;
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;
  const loopRef = useRef(loop);
  loopRef.current = loop;

  useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let disposed = false;
    let running = false;
    let raf = 0;
    let lastMs = 0;
    let simMs = 0;
    let staticMode = false;
    let stoppedAtEnd = false;

    let cssW = 0;
    let cssH = 0;
    let dpr = 1;

    // No hex fallback: a literal here would necessarily encode one theme's
    // polarity, so a dark-theme user could get a wrong-polarity flash on
    // first paint before the token read below lands. readTokens() runs
    // synchronously, before resize()/applyMode() ever call draw(), so these
    // are never read unassigned.
    let bg = "";
    let fg = "";
    let muted = "";
    let family = "ui-monospace, monospace";

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      bg = cs.getPropertyValue("--background").trim();
      fg = cs.getPropertyValue("--foreground").trim();
      muted = cs.getPropertyValue("--ns-muted").trim();
    };
    readTokens();

    const readFont = () => {
      family = getComputedStyle(wrap).fontFamily || family;
    };
    readFont();

    const resize = () => {
      const { width, height } = wrap.getBoundingClientRect();
      if (width < 2 || height < 2) return;
      cssW = width;
      cssH = height;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    let lastAnnounced = -1;
    let lastAnnounceAt = -Infinity;

    const draw = (msIntoCycle: number) => {
      if (cssW <= 0 || cssH <= 0) return;
      const frame = computeFrame(msIntoCycle);

      ctx.clearRect(0, 0, cssW, cssH);
      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, cssW, cssH);

      const cx = cssW / 2;
      const cy = cssH / 2;
      // Outer ring diameter ~0.34 of the smaller dimension — the target is
      // an object sitting inside a generous field, not the frame itself.
      // Numeral, arm, guide ring and control-glyph placement all derive
      // from radius below, so the whole assembly scales proportionally at
      // any container size, full-bleed or a small registry preview card.
      const radius = Math.min(cssW, cssH) * 0.17;

      // inner guide ring — quiet structural echo of the outer target ring.
      // --border is a near-zero-contrast separator token (~1.1:1 in light
      // theme), so a hairline drawn IN it would be invisible; --ns-muted is
      // the token whose luminance sits strictly between bg and fg in both
      // themes, so the hairline weight comes from lineWidth, not from --border.
      ctx.beginPath();
      ctx.arc(cx, cy, radius * 0.62, 0, Math.PI * 2);
      ctx.strokeStyle = muted;
      ctx.lineWidth = Math.max(1, radius * 0.01);
      ctx.globalAlpha = 1;
      ctx.stroke();

      // outer target ring — always on screen, every phase, so the black
      // beat never reads as a dead render
      const ringScale = 1 + 0.14 * frame.flashAmt;
      ctx.beginPath();
      ctx.arc(cx, cy, radius * ringScale, 0, Math.PI * 2);
      ctx.strokeStyle = fg;
      ctx.lineWidth = Math.max(2, radius * 0.035);
      ctx.globalAlpha = 1;
      ctx.stroke();

      // 2-pop stand-in: a one-frame flash of the target RING (not a filled
      // disc — the numeral and arm must stay legible through it) to full
      // --foreground value at full alpha, thickened, plus the scale-pulse
      // above — value only, never accent, so both themes flash to equal R/G/B
      if (frame.flashAmt > 0) {
        ctx.beginPath();
        ctx.arc(cx, cy, radius * ringScale, 0, Math.PI * 2);
        ctx.strokeStyle = fg;
        ctx.lineWidth = Math.max(2, radius * (0.035 + 0.05 * frame.flashAmt));
        ctx.globalAlpha = 1;
        ctx.stroke();
      }

      // clock arm — one full revolution per digit step, starting at 12
      const armLen = radius * ringScale * 0.92;
      const armAngle = frame.angle - Math.PI / 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx + Math.cos(armAngle) * armLen, cy + Math.sin(armAngle) * armLen);
      ctx.strokeStyle = fg;
      ctx.lineWidth = Math.max(2, radius * 0.045);
      ctx.lineCap = "round";
      ctx.stroke();

      // control-frame glyphs — only during the "4" step, hairline weight
      // (weight, not a --border color — see the guide-ring note above),
      // one in each corner of the ring's bounding box, as in the source
      if (frame.showControls) {
        const off = radius * 0.8;
        const glyphSize = Math.max(10, radius * 0.16);
        ctx.font = `${glyphSize}px ${family}`;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.lineWidth = Math.max(1, glyphSize * 0.06);
        ctx.strokeStyle = muted;
        ctx.globalAlpha = 1;
        const corners: [string, number, number][] = [
          ["C", cx - off, cy - off],
          ["C", cx + off, cy - off],
          ["F", cx - off, cy + off],
          ["F", cx + off, cy + off],
        ];
        for (const [glyph, gx, gy] of corners) {
          ctx.strokeText(glyph, gx, gy);
        }
      }

      // numeral — ghost of the outgoing/target digit in --ns-muted, then
      // (outside the black beat) the current digit solid in --foreground.
      // "middle" textBaseline centers on the font's full ascent+descent
      // box, which reserves room for descenders (g/j/p/y) that digits never
      // use — left uncorrected, every numeral renders visibly above the
      // ring's true center. optical Y per glyph pulls it back onto center:
      // measureText's actualBoundingBox*/descent are reported relative to
      // the "middle" anchor already in place, so (ascent - descent) / 2 is
      // exactly the remaining offset between that anchor and the glyph's
      // own visual midpoint.
      const numSize = radius * 1.15;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.font = `700 ${numSize}px ${family}`;
      const opticalY = (digit: number) => {
        const m = ctx.measureText(String(digit));
        return cy + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
      };

      if (frame.ghostDigit !== null && frame.ghostAlpha > 0) {
        ctx.fillStyle = muted;
        ctx.globalAlpha = frame.ghostAlpha;
        ctx.fillText(String(frame.ghostDigit), cx, opticalY(frame.ghostDigit));
      }
      if (frame.phase === "countdown") {
        ctx.fillStyle = fg;
        ctx.globalAlpha = 1;
        ctx.fillText(String(frame.digit), cx, opticalY(frame.digit));
      }
      ctx.globalAlpha = 1;

      // sr-only live region — throttled well below the 450ms tick so a
      // screen reader gets one legible announcement per digit, not seven
      if (
        liveRef.current &&
        frame.digit !== lastAnnounced &&
        performance.now() - lastAnnounceAt >= ANNOUNCE_MIN_MS
      ) {
        lastAnnounced = frame.digit;
        lastAnnounceAt = performance.now();
        liveRef.current.textContent =
          frame.phase === "beat" ? "cue" : String(frame.digit);
      }
    };

    const loopFrame = (nowMs: number) => {
      if (!running) return;
      const dt = Math.min(50, lastMs ? nowMs - lastMs : 16.7);
      lastMs = nowMs;
      simMs += dt;
      if (simMs >= CYCLE_MS) {
        onCompleteRef.current?.();
        if (loopRef.current) {
          simMs %= CYCLE_MS;
        } else {
          simMs = CYCLE_MS - 1;
          stoppedAtEnd = true;
          draw(simMs);
          sleep();
          setStatus(false);
          return;
        }
      }
      draw(simMs);
      raf = requestAnimationFrame(loopFrame);
    };

    const wake = () => {
      if (running || disposed || staticMode || stoppedAtEnd) return;
      running = true;
      lastMs = 0;
      raf = requestAnimationFrame(loopFrame);
    };
    const sleep = () => {
      running = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const setStatus = (busy: boolean) => {
      statusRef.current?.setAttribute("aria-busy", busy ? "true" : "false");
    };

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;
    const applyMode = () => {
      if (reduced || pausedRef.current) {
        staticMode = true;
        sleep();
        draw(reduced ? STATIC_MS : simMs);
        setStatus(false);
      } else {
        staticMode = false;
        setStatus(!stoppedAtEnd);
        wake();
      }
    };
    const onMq = () => {
      reduced = mq.matches;
      applyMode();
    };
    mq.addEventListener("change", onMq);

    let onScreen = true;
    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries.some((en) => en.isIntersecting);
        if (!onScreen) sleep();
        else if (!staticMode && !document.hidden) wake();
      },
      { threshold: 0 }
    );
    io.observe(wrap);

    const onVis = () => {
      if (document.hidden) sleep();
      else if (!staticMode && onScreen) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    const ro = new ResizeObserver(() => {
      resize();
      if (staticMode) draw(reduced ? STATIC_MS : simMs);
    });
    ro.observe(wrap);
    resize();

    const themeObserver = new MutationObserver(() => {
      readTokens();
      if (staticMode) draw(reduced ? STATIC_MS : simMs);
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    let lastPolledPaused = pausedRef.current;
    const poll = window.setInterval(() => {
      if (pausedRef.current !== lastPolledPaused) {
        lastPolledPaused = pausedRef.current;
        applyMode();
      }
    }, 140);

    applyMode();

    return () => {
      disposed = true;
      sleep();
      ro.disconnect();
      io.disconnect();
      themeObserver.disconnect();
      mq.removeEventListener("change", onMq);
      document.removeEventListener("visibilitychange", onVis);
      window.clearInterval(poll);
    };
  }, []);

  return (
    <div
      ref={wrapRef}
      data-curtain-leader-countdown={uid}
      className={`relative h-full w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
      {/* separate siblings, not nested — an aria-busy=true ancestor tells AT
          to withhold updates from its whole subtree, which would silently
          swallow the throttled live-region announcements below */}
      <div ref={statusRef} role="status" aria-busy="true" className="sr-only" />
      <span ref={liveRef} aria-live="polite" className="sr-only" />
    </div>
  );
}

CurtainLeaderCountdown.displayName = "CurtainLeaderCountdown";

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/curtain-leader-countdown.tsx, so point the import there.

Use when a full-bleed preloader/route-transition curtain built as a timing instrument, not a drape — a canvas-drawn SMPTE film-leader countdown ticking numerals to a cut, with no fabric and no rigging choreography at all; pick curtain-traveler-draw, curtain-tab-diagonal or curtain-austrian-gather instead when the loading moment should read as a real curtain parting, peeling or gathering rather than a broadcast countdown.

Install

npx shadcn add https://design.helpmarq.com/r/curtain-leader-countdown.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <CurtainLeaderCountdown onComplete? loop? paused? className?> as a full-bleed route curtain modeled on the SMPTE Universal Leader film countdown — the real broadcast/projection standard, not an invented loading screen. A rotating clock-arm sweeps a circular target once per digit step while numerals tick 8->2 on a canvas-drawn countdown field; C/C/F/F control-frame glyphs flash in the ring's corners only during the '4' step, exactly where the real leader places them. COMPRESSED, NOT LITERAL: the historical leader runs one numeral per real second (~9s total); this component compresses to ~450ms per digit (~3.5s/cycle) for preview-card scale, a deliberate departure from the standard, not a fidelity claim. THE 2-POP HAS NO VISUAL EQUIVALENT IN THE SOURCE: the leader's '2-pop' is an audio cue; here it's translated as a one-frame flash of the target ring to full --foreground plus a brief scale-pulse, an invented stand-in, stated as such. PALETTE: ring and numerals in --foreground, black field in --background, ghost/inactive digit in --ns-muted, control-frame hairlines in --border — no --ns-accent anywhere, including the flash, which goes to full luminance instead. The countdown-and-flash cycle is the unconditional idle loop from mount, no autoplay descriptor needed; any phase must read as complete (numeral + arm angle + ring together) since the no-autoplay verifier can land anywhere in the cycle. prefers-reduced-motion freezes on the '4' step, where control-frame glyphs are visible and the arm sits at a clean quadrant angle — the most structured single frame, not t=0.

Props

PropTypeDefaultDescription
onComplete?() => void—Called once per completed cycle, right as the black beat ends.
loop?booleantrueRestart automatically after each cycle. @default true
paused?boolean—Freeze the loop on its current frame without unmounting.
className?string——
style?React.CSSProperties——