Countdown
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-countdown" role="status" aria-label="Loading">
<span class="loader-countdown__perfs" aria-hidden="true"></span>
<span class="loader-countdown__frame" aria-hidden="true">
<span class="loader-countdown__dial"></span>
<span class="loader-countdown__cross"></span>
<span class="loader-countdown__window">
<span class="loader-countdown__strip">
<span>5</span>
<span>4</span>
<span>3</span>
<span>2</span>
<span>1</span>
</span>
</span>
<span class="loader-countdown__grain"></span>
</span>
<span class="loader-countdown__perfs" aria-hidden="true"></span>
</div>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: 64px; margin: -32px 0 0 -40px; overflow: hidden; }
.loader-countdown__strip { display: grid; animation: loader-countdown-count 5s steps(5, end) infinite; }
.loader-countdown__strip span { height: 64px; color: var(--mark); font: 700 60px/64px 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(-320px); } }
@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(-128px); }
}Goal: Create a "Countdown" loader.
Visual behavior: A film leader counting down 5 to 1: a wedge sweeps round the rings each second while the frame jitters and the grain flickers.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The sweep is a conic-gradient driven by a registered @property angle, so the wedge animates smoothly round the dial once a second.
- The numerals are one strip moved on steps(5), changing exactly as the wedge completes a turn.
- Perforations run past both edges, the frame jitters by fractions of a pixel, and grain and a scratch are re-dealt a few times a second.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Countdown" loader.
Visual behavior: A film leader counting down 5 to 1: a wedge sweeps round the rings each second while the frame jitters and the grain flickers.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The sweep is a conic-gradient driven by a registered @property angle, so the wedge animates smoothly round the dial once a second.
- The numerals are one strip moved on steps(5), changing exactly as the wedge completes a turn.
- Perforations run past both edges, the frame jitters by fractions of a pixel, and grain and a scratch are re-dealt a few times a second.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.