Countdown

No dependencies
Code format
React + Tailwind
const DIGITS = ['5', '4', '3', '2', '1'];
const H = 64; // one numeral's height in the counter strip

const css = `
  @property --loader-countdown-sweep { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
  .loader-countdown { --film: light-dark(#ebedf0, #232529); --line: light-dark(#5f626a, #a7abb2); --mark: light-dark(#373a40, #f1f2f3); --base: light-dark(#50535b, #2e3035); position: relative; display: grid; grid-template-columns: 18px 1fr 18px; width: 230px; height: 172px; border-radius: 4px; background: var(--base); overflow: hidden; animation: loader-countdown-jitter 0.24s steps(1) infinite; }
  /* The perforations either side run past, so the film reads as moving through the gate. */
  .loader-countdown__perfs { position: relative; overflow: hidden; }
  .loader-countdown__perfs::before { content: ''; position: absolute; left: 4px; right: 4px; top: -16px; bottom: -16px; border-radius: 2px; background: repeating-linear-gradient(light-dark(#fbfcfd, #121417) 0 9px, transparent 9px 16px); animation: loader-countdown-run 0.25s linear infinite; }
  .loader-countdown__frame { position: relative; margin: 8px 3px; border-radius: 12px; background: radial-gradient(circle, color-mix(in oklab, var(--film) 86%, light-dark(#373a40, #f1f2f3)), var(--film) 70%); box-shadow: inset 0 0 18px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); overflow: hidden; }
  /* The leader: two rings and a cross, with a wedge sweeping a full turn every second. */
  .loader-countdown__dial { position: absolute; left: 50%; top: 50%; width: 132px; height: 132px; margin: -66px; border-radius: 50%; background: conic-gradient(color-mix(in oklab, var(--mark) 22%, transparent) var(--loader-countdown-sweep), transparent 0); animation: loader-countdown-sweep 1s linear infinite; }
  .loader-countdown__dial::before, .loader-countdown__dial::after { content: ''; position: absolute; border-radius: 50%; border: 2px solid var(--line); }
  .loader-countdown__dial::before { inset: 0; }
  .loader-countdown__dial::after { inset: 13px; }
  .loader-countdown__cross { position: absolute; inset: 0; background: linear-gradient(var(--line), var(--line)) center / 100% 1.5px no-repeat, linear-gradient(var(--line), var(--line)) center / 1.5px 100% no-repeat; opacity: 0.7; }
  .loader-countdown__window { position: absolute; left: 50%; top: 50%; width: 80px; height: ${H}px; margin: ${-H / 2}px 0 0 -40px; overflow: hidden; }
  .loader-countdown__strip { display: grid; animation: loader-countdown-count 5s steps(5, end) infinite; }
  .loader-countdown__strip span { height: ${H}px; color: var(--mark); font: 700 ${H - 4}px/${H}px Futura, 'Avenir Next', 'Century Gothic', 'Helvetica Neue', sans-serif; text-align: center; }
  /* Grain and the odd scratch, re-dealt a few times a second. */
  .loader-countdown__grain { position: absolute; inset: -20px; background: radial-gradient(circle, color-mix(in oklab, var(--mark) 40%, transparent) 0.5px, transparent 0.9px) 0 0 / 13px 17px, radial-gradient(circle, color-mix(in oklab, var(--mark) 30%, transparent) 0.4px, transparent 0.8px) 5px 9px / 19px 11px, radial-gradient(circle, color-mix(in oklab, light-dark(#fbfcfd, #121417) 50%, transparent) 0.6px, transparent 1px) 3px 4px / 23px 29px; opacity: 0.45; animation: loader-countdown-grain 0.36s steps(1) infinite; pointer-events: none; }
  .loader-countdown__grain::after { content: ''; position: absolute; top: 0; bottom: 0; left: 62%; width: 1px; background: color-mix(in oklab, var(--mark) 55%, transparent); opacity: 0; animation: loader-countdown-scratch 1.7s steps(1) infinite; }
  @keyframes loader-countdown-sweep { to { --loader-countdown-sweep: 360deg; } }
  @keyframes loader-countdown-count { to { transform: translateY(${-H * DIGITS.length}px); } }
  @keyframes loader-countdown-run { to { transform: translateY(16px); } }
  @keyframes loader-countdown-jitter { 0% { transform: none; } 25% { transform: translate(0.6px, -0.5px); } 50% { transform: translate(-0.4px, 0.4px); } 75% { transform: translate(0.3px, 0.8px); } }
  @keyframes loader-countdown-grain { 0% { background-position: 0 0, 5px 9px, 3px 4px; } 33% { background-position: 7px 11px, 13px 2px, 17px 21px; } 66% { background-position: 4px 6px, 2px 7px, 9px 13px; } }
  @keyframes loader-countdown-scratch { 0%, 20%, 60% { opacity: 0; } 10% { opacity: 1; left: 62%; } 45% { opacity: 0.8; left: 27%; } 80% { opacity: 0.6; left: 78%; } }
  @media (prefers-reduced-motion: reduce) {
    .loader-countdown, .loader-countdown *, .loader-countdown *::before, .loader-countdown *::after { animation: none !important; }
    .loader-countdown__dial { --loader-countdown-sweep: 120deg; }
    .loader-countdown__strip { transform: translateY(${-H * 2}px); }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-countdown" precedence="kit">
        {css}
      </style>
      <div className="loader-countdown" role="status" aria-label="Loading">
        <span className="loader-countdown__perfs" aria-hidden="true" />
        <span className="loader-countdown__frame" aria-hidden="true">
          <span className="loader-countdown__dial" />
          <span className="loader-countdown__cross" />
          <span className="loader-countdown__window">
            <span className="loader-countdown__strip">
              {DIGITS.map((d) => (
                <span key={d}>{d}</span>
              ))}
            </span>
          </span>
          <span className="loader-countdown__grain" />
        </span>
        <span className="loader-countdown__perfs" aria-hidden="true" />
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/loader-countdown
Source on GitHub

About this component

A film leader counting down 5 to 1: a wedge sweeps round the rings each second while the frame jitters and the grain flickers.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, keyframe
Techniques
keyframes, css property, conic gradient, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the leader holds on 3 with the wedge a third of the way round.

More loaders

See all
Elevator
Fan
Goo
Marble Run