TL;DR
For a reliable countdown animation, use CSS to animate the digits and progress ring, but calculate remaining time in Vue from a deadline. This guide builds a 30-second timer with start, pause, resume, reset, warning, and completion states. The digits remain readable when motion is reduced, and the timer never treats an animation frame as elapsed time.
A countdown is two interfaces at once: a numerical answer to “how much time is left?” and a visual signal that the end is approaching. When those disagree, the attractive ring becomes a liability. I will build a small Vue 3 component with ordinary CSS and SVG, then show exactly what to check when the tab goes into the background or the timer reaches zero.
Table of contents
- Before you start
- 1. Separate the time source from the animation
- 2. Create a Vue countdown with a reactive SVG ring
- 3. Tune CSS keyframes, steps, and progress timing
- 4. Make warning, completion, and reduced-motion states understandable
- 5. Verify drift, restart, expiry, and layout
- FAQ
- Put the timer into a real interface
- Sources
- Recommended Reads
Before you start
You need a Vue 3 project that supports single-file components, a browser, and a place to render CountdownTimer.vue. The example uses native buttons and Vue imports, not a DOM Studio package or a timer library. Its 30-second duration is deliberately local and pauses when the user presses Pause. Replace that rule if your deadline is controlled by a server: pausing a visual indicator must not extend a real booking, auction, or security deadline.
We will use a monotonic browser timestamp for this short, in-page session. performance.now() measures elapsed time without being moved backward by a manual system-clock change, although browser and operating-system sleep behavior varies. A real-world expiry needs an authoritative timestamp and server-side enforcement. MDN explains the clock and its sleep caveat.
1. Separate the time source from the animation
Start with four states: idle, running, paused, and completed. Store remaining milliseconds, not an integer that you subtract from every time an interval fires. When Start or Resume is pressed, calculate a deadline as performance.now() + remainingMs. Each later callback recomputes max(0, deadline - performance.now()). When Pause is pressed, capture the current remainder and stop scheduling updates. Reset restores the original duration.
That distinction matters because setInterval() specifies a delay between callbacks, not a guarantee that each callback runs at the exact scheduled instant. Browsers can also throttle timers in background tabs. The callback in our component is a chance to recalculate time, not the clock itself. MDN documents interval delays and background-tab timer throttling.

Check: after leaving the tab for several seconds while the timer runs, the next rendered value should reflect elapsed time instead of continuing from the last displayed second. If your product must act at the precise expiry instant, do not rely on a delayed browser callback to perform that action; enforce the deadline in the service that owns it.
2. Create a Vue countdown with a reactive SVG ring
Copy the following into CountdownTimer.vue and render it from a parent component. It is a self-contained, client-interactive example. There are no API calls, persistence rules, externally supplied props, or server synchronization to configure. Vue’s ref(), computed(), template bindings, and onUnmounted() are documented in its reactivity, computed-property, and lifecycle guides.
<!-- CountdownTimer.vue -->
<script setup>
import { computed, onUnmounted, ref } from 'vue'
const DURATION_MS = 30_000
const circumference = 2 * Math.PI * 45
const remainingMs = ref(DURATION_MS)
const phase = ref('idle')
const message = ref('Ready to start')
const secondsLeft = computed(() => Math.ceil(remainingMs.value / 1000))
const fractionLeft = computed(() => remainingMs.value / DURATION_MS)
const offset = computed(() => circumference * (1 - fractionLeft.value))
const displayTime = computed(() => {
const minutes = Math.floor(secondsLeft.value / 60)
const seconds = secondsLeft.value % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
let deadline = 0
let ticker = null
let warned = false
function stopTicker() {
if (ticker !== null) {
window.clearInterval(ticker)
ticker = null
}
}
function update() {
if (phase.value !== 'running') return
remainingMs.value = Math.max(0, deadline - performance.now())
if (remainingMs.value === 0) {
phase.value = 'completed'
stopTicker()
message.value = 'Countdown complete'
} else if (!warned && remainingMs.value <= 10_000) {
warned = true
message.value = 'Ten seconds remaining'
}
}
function start() {
if (phase.value === 'running' || phase.value === 'completed') return
deadline = performance.now() + remainingMs.value
phase.value = 'running'
message.value = 'Countdown running'
ticker = window.setInterval(update, 100)
update()
}
function pause() {
if (phase.value !== 'running') return
update()
if (phase.value !== 'running') return
stopTicker()
phase.value = 'paused'
message.value = 'Countdown paused'
}
function reset() {
stopTicker()
remainingMs.value = DURATION_MS
phase.value = 'idle'
warned = false
message.value = 'Timer reset to 30 seconds'
}
onUnmounted(stopTicker)
</script>
<template>
<section
class="clock"
:class="{
'clock--warning': phase === 'running' && secondsLeft <= 10,
'clock--complete': phase === 'completed'
}"
aria-label="Session timer"
>
<h2>Session countdown</h2>
<div class="clock__dial" role="timer" aria-label="Session countdown">
<svg viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<circle class="clock__track" cx="50" cy="50" r="45" />
<circle
class="clock__ring"
:class="{ 'clock__ring--instant': phase !== 'running' }"
cx="50" cy="50" r="45"
:stroke-dasharray="circumference"
:stroke-dashoffset="offset"
/>
</svg>
<span :key="secondsLeft" class="clock__digits">{{ displayTime }}</span>
</div>
<p class="clock__state">{{ phase === 'completed' ? 'Complete' : phase }}</p>
<p role="status" aria-atomic="true">{{ message }}</p>
<div class="clock__actions">
<button type="button" :disabled="phase === 'running' || phase === 'completed'" @click="start">
{{ phase === 'paused' ? 'Resume' : 'Start' }}
</button>
<button type="button" :disabled="phase !== 'running'" @click="pause">Pause</button>
<button type="button" @click="reset">Reset</button>
</div>
</section>
</template>
<style scoped>
.clock {
--accent: #2563eb;
max-inline-size: 22rem;
padding: 1.5rem;
border: 1px solid #cbd5e1;
border-radius: 1rem;
color: #152238;
background: #fff;
text-align: center;
}
.clock--warning { --accent: #a44b05; }
.clock--complete { --accent: #17654a; }
.clock h2 { margin: 0 0 1rem; font-size: 1.2rem; }
.clock__dial { display: grid; place-items: center; margin-inline: auto; inline-size: min(100%, 14rem); }
.clock__dial > * { grid-area: 1 / 1; }
.clock__dial svg { display: block; inline-size: 100%; fill: none; }
.clock__track, .clock__ring { stroke-width: 7; }
.clock__track { stroke: #d9e1ec; }
.clock__ring {
stroke: var(--accent);
stroke-linecap: butt;
transform: rotate(-90deg);
transform-origin: 50% 50%;
transition: stroke-dashoffset 120ms linear, stroke 160ms ease;
}
.clock__ring--instant { transition: none; }
.clock__digits {
font: 700 clamp(1.8rem, 7vw, 3rem) / 1.2 system-ui, sans-serif;
font-variant-numeric: tabular-nums;
animation: digit-enter 180ms ease-out;
}
.clock__state { font-weight: 700; text-transform: capitalize; }
.clock__actions { display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; }
.clock__actions button { padding: .55rem .85rem; cursor: pointer; }
.clock__actions button:disabled { cursor: not-allowed; opacity: .55; }
@keyframes digit-enter {
from { opacity: .55; transform: translateY(.2rem); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.clock__ring { transition: none; }
.clock__digits { animation: none; }
}
</style>
The two circles share a radius of 45 SVG units. Their circumference is 2π × 45; setting the dash offset to zero shows the full ring, while increasing it toward the circumference exposes the track underneath. The numeric SVG attribute is derived from the same remaining milliseconds as the digits, so the display and ring agree even after a pause. SVG defines stroke-dashoffset as the start offset for a dash pattern, and it is animatable. See the SVG stroke-dashoffset reference.
Expected result: the first render shows 00:30; Start begins the countdown; Pause freezes both digits and ring; Resume continues from the saved remainder; Reset immediately restores 00:30; and completion shows 00:00 plus “Complete.” The status message changes only at meaningful milestones. If the ring stays full, inspect the two bound SVG dash attributes and verify that the component is actually calling update().
3. Tune CSS keyframes, steps, and progress timing
The ring’s 120ms CSS transition smooths the visual difference between recalculations every 100ms. The keyed digit span gets a short @keyframes entrance when the displayed whole second changes. Neither effect decides when the countdown completes. CSS animation shorthand can set animation-duration, animation-delay, iteration count, and play state; a delay changes when the visual effect starts, not when the underlying task expires. MDN documents these animation properties.
For a visibly discrete ten-step bar, you can use steps(10, end) as a separate decorative option. It divides one ten-second animation into ten equal jumps, with each jump at the end of its interval. The following CSS assumes an existing .demo-bar element and a parent that adds .demo--running on Start; it does not implement a numeric countdown, Pause, or deadline synchronization. MDN specifies how steps() places the jumps.
.demo-bar { transform-origin: left; transform: scaleX(1); }
.demo--running .demo-bar {
animation: decorative-drain 10s steps(10, end) 1 forwards;
}
@keyframes decorative-drain {
to { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.demo--running .demo-bar { animation: none; }
}
A CSS-only bar can be a useful illustration with a fixed, non-interactive duration. It is not interchangeable with the Vue ring above: a page restored after suspension, a restart, or a changed server deadline can leave a free-running animation out of sync. animation-play-state: paused can freeze the effect, but it cannot itself store or verify how much real time remains. MDN describes animation play state.
For another HTML, CSS, and JavaScript walkthrough, the following video shows a basic countdown. Use it to compare visual treatments, then retain the deadline and accessibility checks in this guide when you adapt a demo for an application.
Check: in the working component, inspect the digits at 00:10 and 00:00. A late callback should skip to the correct time, not replay missed seconds or leave the ring animating after completion.
4. Make warning, completion, and reduced-motion states understandable
Color is supplementary. The component changes its accent near ten seconds, but the separate “Ten seconds remaining” status supplies the warning in words. It also announces pause and completion without replacing the digits with a live region that speaks every tick. An ARIA timer has an implicit aria-live value of off; a status is polite, so these roles fit different jobs. MDN’s timer role and status role explain that distinction.

For reduced motion, the component disables the digit entrance and ring transition while preserving the changing text and static ring position. The prefers-reduced-motion media feature lets a user request reduced non-essential movement; it should not erase the underlying state. MDN documents the media feature. Test the live-region messages with the screen readers your audience uses rather than assuming identical announcement timing across browser and assistive-technology combinations.
Check: turn on reduced motion and run the countdown with the ring obscured. You should still be able to identify the time, warning, paused state, and completion from text alone. If a timer starts automatically alongside other content, review whether it needs a pause, stop, or hide control under WCAG’s Pause, Stop, Hide guidance. A high-stakes expiry may need a way to extend time rather than an inaccurate Pause button that implies the deadline has stopped.
5. Verify drift, restart, expiry, and layout
The example’s 100ms interval improves the perceived smoothness of the ring, but the deadline calculation prevents accumulated interval drift. Background tabs may update less often; after a throttled tab becomes active, the next callback corrects the display. A sleeping device is another case: performance.now() does not advance through system sleep consistently across platforms, so a long-lived or cross-device deadline should come from your server, with clock-offset handling and server-side expiration rules defined by your application. MDN covers background throttling and sleep-time differences.
Run a short test pass before integrating this component:
- Boundary: set
DURATION_MSto3_000locally. Confirm00:03on the first render,00:00at completion, and no negative display. With this short duration, the ten-second warning will occur on the first update; use30_000again to inspect the intended threshold transition. - Pause and resume: pause partway through a second, wait, then resume. Remaining time should not fall during the pause, and the ring should not jump backward.
- Repeat actions: press Start twice, Reset while running, then Start again. Only one interval should be active, and the second run should use a fresh deadline and warning.
- Background and teardown: switch tabs until the deadline passes, return, and confirm completion; navigate away while running and confirm the interval is cleared on unmount.
- Presentation: check a narrow viewport, larger text, keyboard focus, reduced motion, and a screen reader. Buttons must remain operable and the completion message must make sense without the ring.
Troubleshooting: if 00:00 remains visible beside a still-moving ring, inspect whether some other CSS animation is still running outside this component. If the time is accurate but the ring stutters, reduce decorative motion rather than making the CSS animation authoritative. If you need a reusable visual meter elsewhere in your app, explore our linear and radial Progress playgrounds; connect any indicator to the feature’s actual remaining-time state instead of inventing a progress value.
FAQ
Can I build a countdown with CSS alone?
Yes, for a fixed visual demonstration, including a stepped bar or a precomposed sequence of digits. For a product timer with pause, restart, background-tab recovery, or a server-controlled expiry, I would let application state determine the time and use CSS for the presentation. CSS animation delay and duration do not provide an authoritative expiry event. MDN documents animation timing.
Why does a countdown lose seconds when I switch tabs?
Background timer callbacks can be delayed. Subtracting one second per callback mistakes a delayed render for a delayed clock. Recalculate from an appropriate deadline instead, and decide separately whether a user-pausable session or a real-world timestamp owns the deadline. See MDN’s Page Visibility API guidance.
Should I announce every remaining second with aria-live?
Usually not. Keep the visible time accessible as a timer, and announce useful events such as the near-expiry warning and completion in a separate status. The timer role does not announce each update by default. See MDN’s ARIA timer reference.
Put the timer into a real interface
You now have a locally pausable countdown that keeps a readable number, animated ring, and explicit state in agreement. Render it in one screen, test its behavior under tab suspension, then decide whether the expiry belongs to the user session or your backend. For another pattern where measured progress must be distinguished from unknown-duration work, read our Vue loading-state implementation guide.
Sources
- Vue: Reactivity Fundamentals
- Vue: Computed Properties
- Vue: Composition API Lifecycle Hooks
- MDN: Performance.now()
- MDN: Window.setInterval()
- MDN: Page Visibility API
- MDN: SVG stroke-dashoffset
- MDN: CSS animation
- MDN: CSS steps()
- MDN: CSS animation-play-state
- MDN: ARIA timer role
- MDN: ARIA status role
- MDN: prefers-reduced-motion
- W3C WAI: Pause, Stop, Hide
- DOM Studio: Progress
