CSS Countdown Timer

A visual countdown using @property integers, CSS counters, and steps() animation.

Published July 8, 2026 Advanced 8 min read

A CSS countdown timer counts down in whole seconds by animating a registered custom property with steps(), so the value jumps rather than sliding. @property gives --countdown an integer type, a keyframe runs it from 10 to 0, and a CSS counter renders whatever it currently holds. The same property feeds a conic-gradient, which is how the ring empties in time with the digits.

One thing to be clear about before you use this. CSS cannot read a clock, so this is a fixed-length loop that starts whenever the element first renders, not a countdown to a date. It is the right tool for a repeating dwell time, a demo, or a visual for a fixed interval. Two further versions follow the code below: one that actually reaches zero, which the version above does not, and one that shows minutes and seconds with padded digits.

Ten seconds, on a loop

Seconds

HTML

<div class="timer-wrap">
  <div class="timer-display">
    <div class="timer-ring"></div>
    <div class="timer-inner">
      <div class="timer-num"></div>
    </div>
  </div>
  <p class="timer-label">Seconds</p>
</div>

CSS

/* Registering the property as an integer is what makes it animatable */
@property --countdown {
  syntax: "<integer>";
  initial-value: 10;
  inherits: false;
}

.timer-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.5rem;
}

.timer-display {
  position: relative;
  width: 140px;
  height: 140px;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* 36deg per second, so 10 seconds sweeps the full circle */
.timer-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(#b8ff57 calc(var(--countdown) * 36deg), #1c1c1e 0);
  animation: countdown-ring 10s steps(10) infinite;
}

.timer-inner {
  position: absolute;
  inset: 8px;
  background: #0c0c0d;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.timer-num {
  --countdown: 10;
  counter-reset: num var(--countdown);
  font-size: 3rem;
  font-weight: 800;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  color: #b8ff57;
  line-height: 1;
  animation: countdown-num 10s steps(10) infinite;
}

.timer-num::after {
  content: counter(num);
}

.timer-label {
  font-size: .8rem;
  color: #88888f;
  letter-spacing: .1em;
  text-transform: uppercase;
}

@keyframes countdown-ring {
  from { --countdown: 10; }
  to   { --countdown: 0; }
}

@keyframes countdown-num {
  from { --countdown: 10; }
  to   { --countdown: 0; }
}

Other ways to build it

A countdown that actually reaches zero

Two changes from the version above. steps(11, jump-none) holds eleven values rather than ten, so the display runs 10 down to 0 with each figure held for exactly one second of the eleven second cycle. And the property is registered with inherits: true and animated once on the wrapper, so the ring and the digits read the same value instead of running two animations that only happen to line up.

HTML

<div class="ct-precise">
  <div class="ct-precise-ring"></div>
  <div class="ct-precise-num"></div>
</div>

CSS

/* inherits: true, so one animation on the parent feeds both children */
@property --ct-value {
  syntax: "<integer>";
  initial-value: 10;
  inherits: true;
}

@keyframes ct-tick {
  from { --ct-value: 10; }
  to   { --ct-value: 0; }
}

.ct-precise {
  position: relative;
  /* both children are absolutely positioned, so the box has to be
     declared here or the element collapses to nothing */
  width: 120px;
  height: 120px;
  /* eleven values over eleven seconds, one second each, zero included */
  animation: ct-tick 11s steps(11, jump-none) infinite;
}

.ct-precise-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(#b8ff57 calc(var(--ct-value) * 36deg), #1c1c1e 0);
}

.ct-precise-num {
  position: absolute;
  inset: 8px;
  border-radius: 50%;
  background: #0c0c0d;
  display: flex;
  align-items: center;
  justify-content: center;
  counter-reset: v var(--ct-value);
  font-size: 2.6rem;
  font-weight: 800;
  color: #b8ff57;
}

.ct-precise-num::after {
  content: counter(v);
}

Minutes and seconds from one element

Two registered properties on two durations that divide into each other cleanly give a real clock display. Seconds run 59 down to 0 across sixty seconds, minutes run 2 down to 0 across a hundred and eighty, and both animations sit on the same element so a single content rule can print them with a colon between. Both need jump-none: with the default jump-end the seconds land on values like 0.98 that round to 1, so the clock reads 2:01 where it should read 2:00 and never shows a zero anywhere. decimal-leading-zero is what pads a single digit to two, without which the display would read 1:7 instead of 1:07.

HTML

<div class="ct-clock"></div>

CSS

@property --ct-mins {
  syntax: "<integer>";
  initial-value: 2;
  inherits: false;
}

@property --ct-secs {
  syntax: "<integer>";
  initial-value: 59;
  inherits: false;
}

@keyframes ct-mins-tick {
  from { --ct-mins: 2; }
  to   { --ct-mins: 0; }
}

@keyframes ct-secs-tick {
  from { --ct-secs: 59; }
  to   { --ct-secs: 0; }
}

.ct-clock {
  counter-reset: mins var(--ct-mins) secs var(--ct-secs);
  font-family: "DM Mono", Consolas, monospace;
  font-size: 3rem;
  font-weight: 800;
  color: #b8ff57;
  /* 60 into 180 exactly, which is why the two never drift.
     jump-none on both, or neither ever reaches 0 */
  animation:
    ct-mins-tick 180s steps(3, jump-none) infinite,
    ct-secs-tick 60s steps(60, jump-none) infinite;
}

.ct-clock::after {
  /* decimal-leading-zero is what turns 7 into 07 */
  content: counter(mins) ":" counter(secs, decimal-leading-zero);
}

How it works

@property registers --countdown as an <integer> type, allowing CSS to animate it as a number. counter-reset: num var(--countdown) sets a CSS counter to that value, and content: counter(num) displays it in a ::after pseudo-element. steps(10) makes the animation jump in 10 discrete steps rather than interpolating smoothly, and each step changes the displayed number. The ring uses conic-gradient driven by the same property.

steps() is the piece that makes this a timer rather than a smooth sweep. Without it the property would pass through every fractional value between 10 and 0, the counter would truncate each one, and the digit would still change roughly once a second, but the ring would drain continuously and the two would never quite agree with each other. steps(10) quantises the whole animation, so the number and the arc move at the same instant, once per second, which is what makes it read as a clock.

The default form of steps(10) is steps(10, jump-end), and that has a consequence worth knowing before you ship one. Ten steps across a range produce ten held values, and the one that gets dropped is the final one. The timer above shows 10, 9, 8 and so on down to 1, then loops straight back to 10. It never displays zero. For a decorative loop nobody notices. For a countdown where zero is the whole point, the fix is steps(11, jump-none), which holds both ends of the range and gives eleven values instead of ten. The first variant below uses it.

The ring is a conic-gradient with its stop position calculated from the same property: conic-gradient(accent calc(var(--countdown) * 36deg), track 0). Ten seconds across 360 degrees is 36 degrees each. The 0 on the second stop is doing real work there. A color stop positioned before the one ahead of it gets clamped to that earlier position, so writing 0 means the track begins exactly where the lit arc ends, whatever the arc happens to be. Without it you would have to compute the second position too.

Sitting in the middle of the ring is a plain disc inset by 8px, filled with the page background. That is the cheaper of the two ways to turn a filled conic disc into a ring. The other is a radial mask, which the loading spinner uses. The disc only works when you know what is behind the element, since it is painting over the gradient rather than cutting it away. On a photo or a gradient background it will show as an obvious circle of the wrong color.

The number and the ring run two separate animations on two separate elements, because --countdown is registered with inherits: false and each element therefore owns its own copy. They stay together because both are created in the same frame with the same duration. Register the property with inherits: true instead, put one animation on the parent, and both children read the same animated value with no chance of drift. The first variant below is built that way, and number counter uses the same arrangement to drive a figure and a bar together.

CSS properties used

@property
Registers --countdown with an integer syntax. An unregistered custom property has no type, so the browser cannot interpolate it and the countdown never moves.
animation-timing-function
steps(n) holds each value for a fixed slice of the duration instead of interpolating. It is the difference between a timer and a sweep.
counter-reset
Sets a named counter to the current value of the property. This is the join between the animation and the digits on screen.
content
counter(num) prints the counter into ::after. A second argument picks a counter style, so counter(secs, decimal-leading-zero) pads single digits to two.
conic-gradient
Draws the ring. The lit arc ends at calc(var(--countdown) * 36deg) and the track picks up from there.
calc
Turns the integer into an angle. var(--countdown) * 36deg is what lets one property drive both a number and a shape.

Browser support

FeatureChromeFirefoxSafariEdge
CSS counters423.112
content on pseudo-elements423.112
CSS animation455.112
conic gradients698312.179
custom properties49311016

Can I Use has no entry for the @property at-rule, so it is not in the table, and it is by a wide margin the newest thing on this page. It reached Chromium several years before Firefox and Safari shipped it. Where it is missing the property stays untyped, nothing animates, and the timer sits on its initial value of 10 without erroring, which at least is a readable failure. The jump-none keyword used in the first variant is a later addition to steps() than the original start and end keywords, so it is worth testing if your support floor is old. Everything else here has been settled for years and needs no prefixes.

Accessibility notes

Screen reader users get nothing from this. The digits are generated content produced by content: counter(), which is not real text, cannot be selected, and is exposed to the accessibility tree inconsistently. If the countdown carries meaning, put the value in the DOM as text and treat the animated version as decoration with aria-hidden="true" on its host.

Do not put a ticking countdown inside an aria-live region. It will be announced on every tick, which floods the screen reader queue with numbers and drowns out anything else on the page. For a real deadline, announce it at a few thresholds rather than continuously, and that logic is not something CSS can express.

A number changing once a second is mild motion, but a full ring draining is not. Honour prefers-reduced-motion by dropping the ring animation and leaving the digits, or by removing the loop so the timer settles on one state. Because the target value is declared on the element rather than only in the keyframes, removing an animation leaves a sensible resting value rather than a blank.

What you can build with it

  • Carousel dwell timers. A small ring showing how long the current slide has left. The loop length and the slide interval are set in one place, so they cannot fall out of step.
  • Auto dismissing notifications. A drain bar or ring on a toast notification, showing the reader how long they have before it goes.
  • Timed quiz questions. A fixed answering window per question, restarted by re-rendering the element rather than by any timer code.
  • Rest intervals. Workout or cooking timers with a fixed length, where the same interval repeats and the exact wall clock time is irrelevant.
  • Video or slideshow progress. A ring that empties across a known duration, matched to the media length by setting one animation-duration.

Mistakes worth avoiding

  • Expecting steps(10) to display zero. It holds ten values and drops the last one, so a ten second countdown reads 10 down to 1 and restarts. Use steps(11, jump-none) when the final value has to appear.
  • Leaving out the @property block. The custom property stays untyped, the browser cannot interpolate it, and the timer freezes on its initial value with nothing reported anywhere.
  • Treating this as a deadline countdown. It has no access to the clock, it restarts on every page load, and every visitor sees a different point in the cycle depending on when they arrived. Dates and real remaining time need a script.
  • Filling the ring's center with a hardcoded color and then moving the component onto a different background. The inner disc paints over the gradient rather than cutting a hole in it, so it shows up as a circle in the wrong color. Use a radial mask when the backdrop is not fixed.
  • Running two animations on two elements when one would do. They only stay in step because they were created together. Register the property with inherits: true and animate the shared parent instead.

Frequently asked questions

Can CSS count down to a specific date?
No. CSS has no access to the current time, so it cannot know how far away a date is. What it can do is run a fixed interval that starts when the element renders. A real deadline countdown needs JavaScript to compute the remaining time, though CSS can still draw it once a script has written the number in.
Why does my CSS countdown never reach zero?
steps(n) defaults to jump-end, which holds n values across the range and discards the last one. A ten step countdown from 10 shows 10 through 1 and then loops. Switch to steps(11, jump-none), which holds both endpoints and gives you all eleven values.
How do I show two digit seconds?
Pass a counter style as the second argument to counter(). counter(secs, decimal-leading-zero) renders 9 as 09 and leaves anything above 9 alone, which is what a clock display needs.
Does the timer keep running if the tab is hidden?
That is up to the browser rather than the CSS, and engines differ on how aggressively they throttle animations in background tabs. It is another reason not to treat this as a source of truth about elapsed time. Use it to show an interval, not to measure one.
How do I change the length of the countdown?
Three numbers have to move together: the animation-duration, the step count inside steps(), and the degrees per unit in the conic-gradient. A 20 second timer is 20s, steps(21, jump-none), and 18deg per second. Getting one of them wrong is what makes a ring finish before the digits do.