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.

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.

Source
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";
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?() => voidCalled once per completed cycle, right as the black beat ends.
loop?booleantrueRestart automatically after each cycle. @default true
paused?booleanFreeze the loop on its current frame without unmounting.
className?string
style?React.CSSProperties