You need a timer that ticks down and looks like it means it. A ring that drains, a number that falls, maybe both. The good news is that a surprising amount of a countdown is pure CSS, no script required. The catch is knowing which part CSS can actually do and which part is lying to you.
Here is the honest split. CSS is great at the look of counting down. It is useless at knowing what time it is. Keep that line in your head and the rest of this is easy.
CSS countdown animation: ring vs ticking number
There are two shapes this request usually takes, and they want different tools.
- The ring. A circular arc that empties (or fills) as the seconds run out. This is a stroke drawing on an SVG circle, or a sweep drawn with a conic gradient. Continuous motion, one long animation.
- The ticking number. An actual digit that jumps 10, 9, 8, and so on. This is a custom property that animates from one integer to another, printed through a CSS counter, stepped so it snaps instead of sliding.
Most real countdowns use both. A drained ring reads as urgency at a glance. The number gives the person the exact figure. Build them separately, stack them, share the same duration so they finish together.
One rule holds across all of it: a CSS animation runs automatically on load and interpolates values between keyframes. It does not check a clock. Ten seconds of animation runs for ten seconds of wall time only if the tab stays awake and the frame rate holds. We will come back to why that matters.
A countdown ring with stroke-dashoffset on an SVG circle
The classic ring is one SVG circle with a dashed stroke, where the dash is exactly as long as the circle itself. Move the dash offset and the visible arc grows or shrinks. That is the whole trick.
The circumference of a circle is 2 × π × r. For r = 45 that is about 283. You could hardcode 283 and hope. Do not. There is a cleaner way that skips the math entirely.
Set pathLength="100" on the circle. Now the browser treats the whole path as 100 units long no matter its real radius, so stroke-dasharray and stroke-dashoffset read like percentages. Offset 0 is a full ring. Offset 100 is empty.
<svg class="ring" viewBox="0 0 100 100" width="120" height="120">
<circle class="ring__track" cx="50" cy="50" r="45" />
<circle class="ring__bar" cx="50" cy="50" r="45" pathLength="100" />
</svg>
.ring { transform: rotate(-90deg); }
.ring__track {
fill: none;
stroke: #1f2937;
stroke-width: 8;
}
.ring__bar {
fill: none;
stroke: #e11d48;
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 100; /* one dash the length of the whole ring */
animation: drain 10s linear forwards;
}
@keyframes drain {
from { stroke-dashoffset: 0; } /* full */
to { stroke-dashoffset: 100; } /* empty */
}
The rotate(-90deg) on the wrapper starts the arc at twelve o’clock instead of three. stroke-linecap: round gives the leading edge a soft cap, which looks nicer as it shrinks. forwards keeps the ring empty at the end instead of snapping back to full.
Want it to fill up instead of drain? Swap the from and to. Want it to go the other way around the dial? Flip the rotate to 90deg or add transform: scaleX(-1) on the bar.
A gotcha worth the ink: stroke-dashoffset is not one of the cheap compositor properties. It repaints the stroke every frame. For one ring nobody will notice. For forty rings on a dashboard, they will. If that is you, read the notes on CSS animation performance before you ship it.
A conic-gradient sweep
No SVG, no dash math. A conic gradient draws a circular sweep straight into an element’s background, and you animate the angle where the color stops.
Here is the part people trip on. You cannot animate a gradient directly. Gradients are not interpolatable values on their own. What you can animate is a custom property that the gradient reads from, but only if you tell the browser that property holds an angle. That is what @property is for.
@property --sweep {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.sweep {
--sweep: 0deg;
width: 120px;
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(#e11d48 var(--sweep), #1f2937 0);
animation: sweep 10s linear forwards;
}
@keyframes sweep {
to { --sweep: 360deg; }
}
Register --sweep as an <angle> and the browser knows how to move it smoothly from 0deg to 360deg. Drop the @property block and the same animation jumps straight to the end with nothing in between, because an unregistered custom property is just a string to CSS and strings do not interpolate.
Want a hole in the middle so it reads as a ring, not a pie? Mask the center out.
.sweep {
-webkit-mask: radial-gradient(farthest-side, #0000 70%, #000 72%);
mask: radial-gradient(farthest-side, #0000 70%, #000 72%);
}
Support note. @property reached Baseline in July 2024. That means Chrome and Edge since 85, Safari since 16.4, Firefox since 128. Wide enough for most production work in 2026, but if you must support something ancient, the SVG ring degrades more gracefully because the offset animation still runs without @property.
Counting numbers with the @property rule and steps()
This is the one people assume needs JavaScript. It does not. You can print an animated integer with zero script, and the mechanism is genuinely clever.
You cannot put a variable directly into content. What you can do is animate a registered integer, feed it into a CSS counter with counter-reset, then print that counter through content. The counter re-reads the property on every frame.
@property --n {
syntax: "<integer>";
initial-value: 10;
inherits: false;
}
.count {
--n: 10;
font: 700 3rem/1 system-ui, sans-serif;
counter-reset: n var(--n);
animation: tick 10s steps(11, jump-none) forwards;
}
.count::after {
content: counter(n);
}
@keyframes tick {
to { --n: 0; }
}
Two pieces make this work. The <integer> syntax lets --n animate numerically, one whole number at a time. The steps() timing function snaps it, so you see 10, then 9, then 8, not a blurry slide through 9.4.
Now the part every tutorial glosses over: which steps() variant. Plain steps(10) is jump-end. It shows the start value, then jumps at the end of each interval, and the final 0 only appears at the very last instant. For a countdown that lands on zero, that reads as slightly off. jump-none is the fix, and the count matters: it places its stops at even fractions of the way through, so eleven integers (ten down to zero) need eleven stops, which is steps(11, jump-none). Use steps(10, jump-none) here by mistake and the browser rounds the in-between values, quietly skipping a number. Eleven stops, one per integer, each holding about a second, and it settles on 0.
If you need the digits to appear one after another, like a PIN revealing itself, stagger them instead. Give each digit its own animation and a later animation-delay, and they light up in sequence. The steps() curve is also the backbone of the CSS animation timing function family, so if the jump-start versus jump-end thing is still fuzzy, that page draws it out.
Where CSS stops and JavaScript starts
Everything above is a performance of counting. None of it knows the actual time. That distinction is the whole game.
Reach for CSS when the countdown is decorative or fixed-length. A ten-second “get ready” ring before a quiz starts. A progress arc on a toast that auto-dismisses. A visual flourish that resets every time the component mounts. CSS wins here because it is cheap, declarative, and needs no runtime.
Reach for JavaScript the moment the number has to be true. A sale that ends Friday at midnight. A token that expires in 4 minutes 12 seconds. Anything tied to a real timestamp. CSS animations drift when the tab is backgrounded, throttled, or the machine drops frames, and a shopper who reloads should not see the timer reset to five full minutes.
The clean pattern is a division of labor. JavaScript owns the truth, computing remaining time from a real deadline. CSS owns the pixels. You can even drive the whole thing from script with Element.animate(), which runs keyframes from JavaScript and hands you back an animation object you can pause, reverse, or seek.
const deadline = Date.now() + 10_000;
function frame() {
const left = Math.max(0, deadline - Date.now());
el.textContent = Math.ceil(left / 1000);
if (left > 0) requestAnimationFrame(frame);
}
frame();
Need to run code the instant the visual finishes? Listen for animationend, which fires when a CSS animation completes. That is your hook to hide the ring, fire the “time’s up” callback, or open the next step. No polling, no guessing.
Don’t forget reduced motion
A countdown is unusual: the motion carries meaning, so you cannot simply switch it off. What you can do is calm it. Drop any decorative pulse or shake, and if the ring sweep bothers people, let the number carry the information on its own.
@media (prefers-reduced-motion: reduce) {
.ring__bar { animation-duration: 0.001s; } /* jump to final state */
.count { animation-timing-function: steps(11, jump-none); }
}
prefers-reduced-motion: reduce detects that someone asked their system for less movement, usually because spinning or sweeping motion makes them ill. Honoring it is not optional polish. For the full treatment, including why a resting hidden state needs extra thought, see accessible animation with prefers-reduced-motion.
That is the whole toolkit. A dashed SVG circle for the ring, a registered angle for the conic sweep, a registered integer plus a counter for the digits, and JavaScript for the one thing CSS refuses to know: what time it actually is.
Related
- CSS animation: the parent guide to @keyframes, timing, and smooth motion.
- Check animation CSS: the self-drawing SVG checkmark, same stroke-dashoffset trick as the ring.
- CSS animation timing function: steps(), cubic-bezier, and reading an easing curve.
- CSS animation performance: why stroke and layout properties cost more than transform.