Home / Articles

Drift-Free Countdown Timers in React: From setTimeout to Pure CSS

Compare setTimeout, requestAnimationFrame and a zero-JavaScript CSS technique for React countdowns, including the single-delay trick that keeps digits in sync.

Publisher: REACTAPP.TOP17 min read4011 words

This article is published in English.

Sources: How I Build a Countdown Timer in React

A voucher card with an expiry date needs a visible clock: hours, minutes and seconds ticking down until the deal disappears. The element is tiny, but it carries a lot of product weight. A timer that lags behind real time, jumps two seconds at once or keeps a laptop fan spinning quietly erodes the user's trust in everything around it. This guide walks through three ways to build that countdown in React, explains exactly why the first one drifts, and ends with a CSS-only technique that can drive hundreds of timers with no per-tick JavaScript at all.

The problem in one sentence

Each voucher carries a claim_end_time. The display should show how much time remains until that moment, formatted as HH:MM:SS, refreshed every second, and it should stop at zero when the voucher expires.

The arithmetic is trivial. The hard part is deciding how and when to recompute it, and that choice determines accuracy, CPU cost and what you can do when the clock runs out.

Approach 1: a setTimeout chain, and why it drifts

The instinctive implementation is a function that updates the display and then schedules itself again one second later.

function tick() {
  updateDisplay();
  setTimeout(tick, 1000);
}
tick();

It reads cleanly and appears to work in a quick test. The trouble lies in how timers are delivered. JavaScript on the page runs on a single main thread, and the event loop hands it one task at a time. A timer callback is just another task, queued behind click handlers, network callbacks, rendering work and whatever React is reconciling. The 1000 you pass is a minimum, not a promise. If the thread is busy when the second elapses, the callback waits, and you might get it after 1,050 ms or 1,200 ms instead. If you want a refresher on why the main thread behaves this way, see our piece on JavaScript's single thread versus the browser's multi-process engine.

What turns occasional lateness into visible drift is the chaining. Every tick schedules the next tick from inside itself, so any delay is inherited by all the ticks that follow. Nothing ever pulls the sequence back to the wall clock. Leave such a loop running for ten minutes, compare it against Date.now(), and you will see it trailing behind.

A timer that decrements a counter on each tick measures callbacks, not elapsed time, and background-tab throttling makes the gap even larger.

You can compensate by measuring how late each callback fired and shortening the next delay accordingly. That works, but you end up maintaining correction logic that fights the platform instead of leaning on it. Either way, the code still runs on the main thread every second.

Approach 2: requestAnimationFrame and recomputing from the clock

requestAnimationFrame (rAF) asks the browser to call your function right before the next repaint, which on most displays happens around 60 times per second. The frequency is not the important part, though. The important change is conceptual: you stop asking "has another second passed?" and instead ask "how much time is left at this exact moment?"

function updateTime() {
  const now = Date.now();
  const remaining = endTime - now;
  if (remaining <= 0) {
    display('00:00:00');
    return;
  }
  display(format(remaining));
  requestAnimationFrame(updateTime);
}
requestAnimationFrame(updateTime);

On every frame the remaining time is derived from scratch, using Date.now() as the single source of truth. No tick counter, no accumulated intervals. If one frame arrives late, the next one simply reads the clock again and shows the correct value. Errors cannot pile up because nothing is carried from one frame to the next except the end time itself. The same principle fixes the setTimeout version too: if each callback recomputes from Date.now() instead of decrementing a counter, the display self-corrects even when callbacks are late.

A reasonable objection: the visible value changes only once per second, so why do any work 60 times per second? In React, the expensive part is not the subtraction but the re-render triggered by setState. So the loop compares on every frame and updates state only when the whole-second value actually changes.

Updating state only when the second changes

Here is that idea inside a component. Note that endTime is a Unix timestamp in seconds, which is why it is multiplied by 1000 before being compared with Date.now().

const updateTime = (): void => {
  const now = Date.now();
  const timeTillStop = endTime * 1000 - now;
  if (timeTillStop <= 0) {
    setTimeLeft({ hours: 0, minutes: 0, seconds: 0 });
    onNoCountdownTimeLeft?.();
    return;
  }
  const totalSeconds = Math.floor(timeTillStop / 1000);
  if (totalSeconds !== lastSecondsRef.current) {
    lastSecondsRef.current = totalSeconds;
    const hours = Math.floor(totalSeconds / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    setTimeLeft({ hours, minutes, seconds });
  }
  animationRef.current = requestAnimationFrame(updateTime);
};

Two refs do the heavy lifting. lastSecondsRef holds the last rendered second. Because it is a ref, writing to it never causes a render; it is simply a memory slot that survives between frames. The loop therefore runs at frame rate, while React renders at most once per second. The optional onNoCountdownTimeLeft callback is called once the time runs out, giving the parent a hook to hide the card, refetch data or show an "expired" state.

The second ref, animationRef, stores the ID returned by requestAnimationFrame. The effect's cleanup function uses it to cancel the pending frame when the component unmounts or endTime changes:

return () => {
  if (animationRef.current) {
    cancelAnimationFrame(animationRef.current);
  }
};

Without that cleanup, a loop would keep running after the user navigates away, calling setState on an unmounted component and wasting CPU. With it, the component is accurate, cheap to render and self-correcting.

A couple of practical notes. Browsers pause rAF callbacks in hidden tabs, which is actually helpful here: the loop stops costing anything in the background, and the first frame after the tab becomes visible reads the clock and jumps straight to the right value. The per-frame comparison is negligible for one timer but adds up across dozens of cards.

Approach 3: a countdown that is pure CSS

The rAF version still executes JavaScript on every frame. The third approach removes that entirely: JavaScript computes a single number when the component mounts, and from then on the browser's animation engine owns every tick.

The countdown becomes a CSS animation that steps through the digits, started with a negative delay so it begins mid-sequence at the correct time.

One clarification about performance before going further. You may read that CSS animations always run on the compositor thread and therefore can never be blocked. That is true only for a small set of properties, mainly transform, opacity and some filters. Animating registered custom properties and generated content, as this technique does, is handled by the browser's style engine on the main thread. The real win is different but still substantial: there are no timers, no React state and no renders per tick, and the browser's own scheduler, which ties animation progress to the document timeline, keeps the values correct.

Making this work requires four CSS features to cooperate. We will go through them one at a time.

Step 1: declare animatable integers with @property

An obvious first attempt is to animate the content property directly. That fails, because content is not interpolatable: the browser has no notion of how a string "59" turns into "58". What you need is a number the browser can animate, and a registered custom property provides exactly that.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
}

@property registers --s with a type. Declaring syntax: '<integer>' tells the browser the value is a genuine integer rather than an opaque token, which makes it interpolatable and therefore animatable. Setting inherits: true lets descendants, including ::after pseudo-elements, read the animated value from their parent. That means a single animation on the timer container drives every digit inside it. For a broader look at registered properties and how they differ from plain custom properties, see our guide to CSS custom properties at runtime and @property.

Each unit gets its own registered property. Hours start at 99, which becomes the upper limit of what this timer can show.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* seconds */
@property --m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* minutes */
@property --h {
  syntax: '<integer>';
  inherits: true;
  initial-value: 99;
} /* hours   */

Step 2: render the integer as padded text with counter()

The integer still has to become zero-padded text (05, not 5). The counter() function renders a counter's value inside content, and its built-in decimal-leading-zero style supplies the two-digit padding for free.

The bridge from custom property to counter is counter-reset: it sets a named counter to the current value of the property, and counter() reads it back. The snippet is CSS even though the source labels it otherwise.

.seconds-digit::after {
  counter-reset: sc var(--s); /* set the "sc" counter = current --s value */
  content: counter(sc, decimal-leading-zero); /* render as "00"–"59" */
}
.minutes-digit::after {
  counter-reset: mc var(--m);
  content: counter(mc, decimal-leading-zero);
}
.hours-digit::after {
  counter-reset: hc var(--h);
  content: counter(hc, decimal-leading-zero);
}

Whenever --s moves to a new integer, the style engine recomputes counter-reset, and the pseudo-element's content updates with it. No script is involved in the update.

Step 3: make the values jump with steps()

By default an animation eases continuously between keyframes. A clock must not glide; it has to snap from one digit to the next. The steps(N, end) timing function divides the duration into N equal, discrete jumps.

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
}
animation: tick-s 60s steps(60, end) infinite;

Sixty steps spread across a sixty-second duration means one jump per second: 59 at the start, 58 after one second, 0 after 59 seconds, and back to 59 when the next iteration begins.

The end keyword (equivalent to jump-end) places each jump at the end of its interval. At exactly t = 0 the animation still shows its from value, because the first jump has not happened yet. This detail matters once negative delays enter the picture: a delay that lands exactly at t = 0 displays 59 rather than 58.

Ending the keyframes at -1 looks odd but is deliberate. The animation interpolates across a range of 60 integers, and because of jump-end it wraps back to from before the final value ever appears. The visible sequence is therefore exactly 59 down to 0, in clean steps of one.

Every unit has a keyframe and step count of its own. Minutes take 60 steps of 60 seconds each, and hours take 100 steps of one hour each:

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
} /* 60 steps × 1s  = 60s  */
@keyframes tick-m {
  from {
    --m: 59;
  }
  to {
    --m: -1;
  }
} /* 60 steps × 60s = 1hr  */
@keyframes tick-h {
  from {
    --h: 99;
  }
  to {
    --h: -1;
  }
} /* 100 steps × 1hr = 100hr */

Step 4: start mid-sequence with a negative delay

This is the central trick. When animation-delay is negative, the browser treats the animation as having begun that many seconds in the past, so it starts at whatever point it would have reached by now.

Say 23 seconds are left in the current minute. The seconds animation starts at 59 and must already have stepped down 36 times to show 23, so a delay of -36s puts it in the right place. In general:

delay = -(59 - s)      where s = remaining seconds within the current minute

To apply this to all three units you first need the total remaining time in seconds, computed from the end timestamp:

const remaining = endTime - Math.floor(Date.now() / 1000);

That total is then split into the portions for each unit. Hours are clamped to 99 because the hours animation only covers 100 steps:

const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99);

Keep the clamp in mind as a product constraint. A voucher that expires in five days would show 99:59:59 and appear stuck until fewer than 100 hours remain. If your deals can run that long, add a days unit or switch formats.

Keeping the digits in sync with a single delay

The obvious next move is to compute a separate delay per unit. It produces a nasty bug: seconds wrap around to 59 while the minutes stay put for far too long.

Why separate delays drift apart

Take remaining = 125, which is 0 hours, 2 minutes and 5 seconds. Calculating the seconds and minutes delays independently gives:

delayS = -(59 - 5)       = -54s     → seconds virtual start: now - 54s
delayM = -(59 - 2) × 60  = -3420s   → minutes virtual start: now - 3420s

Every animation measures its jumps relative to a private virtual start time. Seconds steps fire every second counting from now - 54s; minutes steps fire every 60 seconds counting from now - 3420s. Because the origins differ, the boundaries fall at unrelated wall-clock moments. The seconds digit reaches its next rollover here:

now - 54 + 60 = now + 6s

The minutes digit, however, does not step until:

now - 3420 + (58 × 60) = now - 3420 + 3480 = now + 60s

So the seconds wrap six seconds from now while the minutes change a full minute from now. For 54 seconds the display reads 01:59 when the true value is below 01:00. The time is wrong for most of every minute.

One shared origin

The cure is a shared origin for all three animations, obtained by combining the offsets into a single delay:

const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));
//           = -(  99×3600      +   57×60       +   54   )
//           = -(356400 + 3420 + 54)
//           = -359874s

Now every animation measures from now - 359874s. Their step boundaries are all multiples of their own step duration counted from that one origin. Check the example: within a 60-second window the seconds animation sits at position 54, so its next boundary comes in 6 seconds. Within a 3,600-second window the minutes animation sits at position 3,474, and its next boundary is at 3,480, also 6 seconds away. Both digits change together at now + 6s and stay aligned every 60 seconds after that.

How one number encodes three digits

The combined delay works like a mixed-radix number, much as HH:MM:SS packs three fields into one string. Encoding builds it from the three components:

|delay| = (99 - h) × 3600    ← hours field,   each unit = 3600s
        + (59 - m) × 60      ← minutes field, each unit = 60s
        + (59 - s)           ← seconds field, each unit = 1s

Decoding is what each animation does implicitly by looking only at its own window:

seconds position = |delay| mod 60                 → display 59 - position
minutes position = floor((|delay| mod 3600) / 60) → display 59 - position
hours   position = floor(|delay| / 3600)          → display 99 - position

Running the example through the decoder confirms it. 359,874 modulo 60 is 54, so seconds show 59 − 54 = 5. 359,874 modulo 3,600 is 3,474, which divided by 60 and floored is 57, so minutes show 59 − 57 = 2. 359,874 divided by 3,600 and floored is 99, so hours show 99 − 99 = 0. The display reads 00:02:05, which is correct.

The animations cannot interfere with each other because each field contributes an exact multiple of the next field's cycle: the hours part is always a multiple of 3,600 and the minutes part always a multiple of 60, so neither disturbs the lower positions.

Stopping cleanly at 00:00:00

The tick animations use infinite iteration, so on their own they never stop. When the seconds cycle ends it wraps to 59, and a timer that hits zero would cheerfully start counting down again.

The remedy is one more animation that plays a single time, at exactly the moment of expiry, and pins all three properties to zero. This block is also CSS despite its original label:

@keyframes freeze {
  to {
    --s: 0;
    --m: 0;
    --h: 0;
  }
}
.timer {
  animation-name: tick-s, tick-m, tick-h, freeze;
  animation-duration: 60s, 3600s, 360000s, 1ms;
  animation-timing-function: steps(60, end), steps(60, end), steps(100, end),
    step-start;
  animation-iteration-count: infinite, infinite, infinite, 1;
  animation-fill-mode: none, none, none, forwards;
}

JavaScript assigns the shared delay to the three tick animations and the plain remaining time, as a positive delay, to the freeze animation:

el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;

Three details work together here:

  • step-start (the same as steps(1, jump-start)) jumps to the final keyframe as soon as the animation begins, rather than at the end of its 1 ms duration, so all three values become 0 the instant the delay expires.
  • animation-fill-mode: forwards keeps the final keyframe applied after the animation finishes, so the zeros persist indefinitely.
  • Order in animation-name matters: when several animations set the same property, the one listed later wins. Because freeze is last, its zeros override the tick animations, which keep looping underneath with no visible effect.

If the voucher has already expired when the page loads, the remaining time is 0, the freeze delay is 0s, and the timer displays zeros immediately.

What the CSS approach costs you

The zero-JavaScript design has real limits, and they should drive your decision:

  • No expiry hook. There is no callback, event or state change when the timer reaches zero. If expiry must trigger an API call, hide the card or render something different, you need a separate one-shot setTimeout alongside, which gives back some of the simplicity.
  • Constrained styling. counter() produces plain text. Font, colour and layout are fully under your control, but anything that depends on the current value in JavaScript, such as switching to red under 60 seconds or adding a class at a threshold, requires extra work.
  • Whole-second precision. The remaining time is floored to whole seconds at mount, so the visible ticks can be offset by up to a second from the true second boundaries. For a promotional countdown that rarely matters, but it is not a precision clock.
  • Browser support. The technique depends on @property and animated registered properties; confirm support against your browser matrix in current compatibility data before relying on it.

In exchange, for a read-only countdown that only needs to tick and stop, it is extremely efficient: one animation set per card, no JavaScript per tick, no drift, and nothing to clean up on unmount. It shines when a page renders a grid of hundreds of voucher cards at once.

The React component for the CSS timer

The CSS version changes the shape of the component. There is no state, no animation loop and no cleanup. The component reads the clock once, writes one style property and renders markup. It needs only two hooks from React:

import { useEffect, useRef } from 'react';

The component below computes the remaining seconds, splits them into units, builds the shared delay and writes the four delays to the element. The type prop only switches between two visual styles of the digits.

interface CSSCountdownTimerProps {
  endTime: number; // Unix timestamp in seconds, same as existing prop
  type?: 'solid' | 'regular';
}export function CSSCountdownTimer({
  endTime,
  type = 'solid',
}: CSSCountdownTimerProps) {
  const timerRef = useRef<HTMLDivElement>(null);  useEffect(() => {
    const el = timerRef.current;
    if (!el) return;    const now = Math.floor(Date.now() / 1000);
    const remaining = Math.max(0, endTime - now);    const s = remaining % 60;
    const m = Math.floor(remaining / 60) % 60;
    const h = Math.min(Math.floor(remaining / 3600), 99);    const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));    el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;    // No cleanup needed.
    // There are no JS timers, no rAF loops, no subscriptions.
    // CSS animations stop automatically when the element is removed from the DOM.
  }, [endTime]);  return (
    <div ref={timerRef} className={`timer ${type}`}>
      <span
        className={type === 'solid' ? 'digit hours-digit' : 'hours-digit'}
      />
      <span className="sep">:</span>
      <span
        className={type === 'solid' ? 'digit minutes-digit' : 'minutes-digit'}
      />
      <span className="sep">:</span>
      <span
        className={type === 'solid' ? 'digit seconds-digit' : 'seconds-digit'}
      />
    </div>
  );
}

As the comments note, no cleanup is required: when the element leaves the DOM, its animations go with it. Two refinements are worth considering. First, because useEffect runs after the browser paints, the first frame can briefly show the animations at their default positions before the delay is applied; useLayoutEffect, or computing the delay during render and passing it as an inline style, avoids that flash. Second, the effect depends on endTime, so if the prop changes the delays are recomputed and the animations re-position themselves correctly.

Choosing between the three approaches

A simple decision rule covers most situations:

  • If something must happen when the timer expires, such as a state update, a refetch or an API call, use requestAnimationFrame with clock-based recomputation. It gives you full control and a natural place for the expiry callback.
  • If a page shows lots of timers and none of them trigger logic, reach for the CSS technique and let the browser do the work.
  • If you are using a plain setTimeout chain that decrements a counter, replace it. It is inaccurate and still costs main-thread work every second, so it combines the downsides of both alternatives. The two can also be combined: CSS for display, one shared timer for expiry logic.

Key takeaways

  • Timer delays are minimums; derive remaining time from Date.now() on every update instead of counting ticks, and drift disappears.
  • With rAF, keep the last rendered second in a ref and cancel the frame on unmount.
  • The CSS version rests on four features: @property integers, counter() for padded text, steps() for discrete jumps and a negative delay to start mid-sequence.
  • Share one delay across all units so digits roll over together, and add a freeze animation to hold zeros.