Countdown
Animated timer for deadlines and launches.
Live demo
01Each section below mounts a Countdown with different format and units combinations. The interactive demo at the bottom triggers a real onComplete callback when its 10-second timer hits zero.
Event countdown Β· cards format Β· all four units Β· 31 Dec 2026
The default β each unit in a dark gradient card with a flip animation on every change. Best used for marquee event hero sections.
Digital format Β· compact Β· minutes & seconds only Β· pomodoro timer
format="compact" with just two units gives a clean digital-clock look β
ideal for short timers like Pomodoro intervals or lap counters.
Labels format Β· large numbers Β· quieter than cards
Numbers without the card chrome β works in marketing pages where the countdown should not dominate visually.
Far-future Β· cards format Β· big numbers Β· year 2099
Pointing at a date decades away exercises the layout β five-digit days fit cleanly thanks
to tabular-nums.
Custom units Β· pick which segments appear
Pass any subset and order of ['days', 'hours', 'minutes', 'seconds'] via
the units prop. The remaining time still calculates correctly β only the
display narrows.
Days only
Hours & minutes
Just seconds
onComplete callback Β· 10s timer with toast
This countdown lasts ten seconds. When it hits zero, the onComplete callback fires once and updates the toast. Press the button to
reset and watch it fire again.
Completion message Β· past date renders the message
When the target is already in the past, the component skips the digits entirely and
renders completedMessage with a bounce animation.
Implementation
02<script lang="ts">
import Countdown from '$lib/components/Countdown.svelte';
</script>
β
<Countdown
targetDate="2026-12-31T23:59:59"
units={['days', 'hours', 'minutes', 'seconds']}
format="cards"
onComplete={() => console.log('done')}
/>Countdown ticks once a second, recomputing the time remaining and slicing it into the requested unit list. Three formats β cards, labels, compact β share the same numeric core. ARIA live regions announce the changing values politely, and prefers-reduced-motion suppresses the digit-flip animation.
Logic explainer
03What Does It Do? (Plain English)
Countdown displays a live timer ticking down from "now" toward a target date β days, hours, minutes, and seconds (or any subset). When a number changes, a small flip animation cues the change so the reader's eye latches onto it. When the target hits zero the component fires onComplete and either swaps to a completion message or hides itself.
Three formats: cards (boxed segments with labels), labels (numbers with units beneath), compact (HH:MM:SS separated by a glyph). Think of it as the digital alarm clock at midnight on New Year's Eve, just packaged as a Svelte component.
How It Works (Pseudo-Code)
state:
segments : Array<{ value, label, unit }>
isComplete = false
intervalId = setInterval handle
previousValues : Record<unit, number> // tracked for the change-flash class
helpers:
parseTargetDate(target) β Date (accepts Date | number | string)
getUnitLabel(unit, value) β singular or plural label
formatValue(value, padZeros) β '05' | '5'
hasValueChanged(unit, value) β bool (used to drive .countdown__segment--changed)
calculateTimeRemaining():
target = parseTargetDate(targetDate)
if target invalid:
segments = zeros; clear interval; return
diff = target - now
if diff <= 0:
if not isComplete:
isComplete = true
segments = zeros
onComplete?()
return
totalSeconds = floor(diff / 1000)
totalMinutes = floor(totalSeconds / 60)
totalHours = floor(totalMinutes / 60)
totalDays = floor(totalHours / 24)
values = {
days: totalDays,
hours: totalHours % 24,
minutes: totalMinutes % 60,
seconds: totalSeconds % 60
}
for unit in units: previousValues[unit] = current segments[unit].value
segments = units.map(unit => ({ value: values[unit], label: ..., unit }))
on mount:
calculateTimeRemaining()
intervalId = setInterval(calculateTimeRemaining, 1000)
on destroy:
clearInterval(intervalId)The whole thing is a setInterval ticking once per second. Every tick recomputes from scratch β there's no incremental subtraction, so clock skew, sleep/resume, and tab-throttling all heal themselves on the next tick.
The Core Concept: Modular Arithmetic, Not Incremental Subtraction
A naive countdown subtracts 1 from seconds each tick, rolls over to minutes at zero, and so on. That accumulates drift over hours (the setInterval is approximate, especially when the tab is throttled). The fix is to recompute every unit from a single ground-truth diff = target - now on each tick:
diff = target.getTime() - now.getTime() (milliseconds)
totalSeconds = β diff / 1000 β
totalMinutes = β totalSeconds / 60 β
totalHours = β totalMinutes / 60 β
totalDays = β totalHours / 24 β
values:
days = totalDays
hours = totalHours mod 24
minutes = totalMinutes mod 60
seconds = totalSeconds mod 60 Example: diff = 90061000 ms
totalSeconds = 90061
totalMinutes = 1501
totalHours = 25
totalDays = 1
display:
days: 1
hours: 25 mod 24 = 1
minutes: 1501 mod 60 = 1
seconds: 90061 mod 60 = 1
β "1 day, 1 hour, 1 minute, 1 second"This means a tab that was suspended for 30 minutes resumes at the correct time on the next tick, not 30 minutes behind. The units prop selects which subset to display; the modular arithmetic always runs over all four so the maths is consistent.
CSS Animation Strategy
When previousValues[unit] !== currentValue, the segment gains a transient .countdown__segment--changed class. CSS keyframes fade the new value in or apply a small flip-style transform β enough to draw the eye without distracting. The component reads prefers-reduced-motion indirectly via the global media query β the keyframes are wrapped in @media (prefers-reduced-motion: no-preference) so reduced-motion users see static digit changes.
compact format uses a separator span between segments (default :); cards and labels use flex gap. The aria-live="polite" region announces ticks to assistive tech β but at one update per second it doesn't spam screen readers; the aria-label on each segment carries the human-readable form ("5 Hours").
Performance
- One
setIntervalat 1 Hz. Cheap. - Every tick recomputes from scratch; no accumulator drift.
previousValuesis a tinyRecord<CountdownUnit, number>, not a Map, so reads/writes are O(1).- DOM cost: one segment per displayed unit (max 4). Re-renders are flat regardless of how long until the target.
setIntervalis cleared inonDestroyβ no orphan timer after navigation.- Invalid
targetDateclears the interval immediately and renders zeros, so we don't burn ticks on garbage input.
State Flow Diagram
[mounted]
β
β calculateTimeRemaining() once
β setInterval(calculateTimeRemaining, 1000)
βΌ
[ticking] segments update each second
β
β diff > 0
βββββ (loop) βββββ
β β
β invalid date β
βΌ β
[zeroed] β
β β
β β
βΌ β
ββββββββββββββββββ
β
β diff <= 0 (first time)
βΌ
[complete] isComplete = true
onComplete?()
if hideOnComplete: render nothing
else: render completedMessage
on destroy: clearIntervalProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
targetDate |
Date | number | string |
required | Target date/time. String parsed by new Date(); number is a UTC ms timestamp. |
units |
CountdownUnit[] |
['days', 'hours', 'minutes', 'seconds'] |
Which units to display (CountdownUnit = 'days' | 'hours' | 'minutes' | 'seconds'). |
format |
'cards' | 'labels' | 'compact' |
'cards' |
Display style. compact separates segments with separator; cards/labels use the unit label below. |
showLabels |
boolean |
true |
Show "Days" / "Hours" / etc. labels (ignored in compact mode). |
separator |
string |
':' |
Glyph rendered between segments in compact mode. |
padZeros |
boolean |
true |
Zero-pad single digits (05 instead of 5). |
completedMessage |
string |
"Time's up!" |
Text shown when the countdown reaches zero. |
onComplete |
() => void |
undefined |
Callback fired exactly once when the countdown completes. |
hideOnComplete |
boolean |
false |
Hide the entire component when complete (otherwise completedMessage is shown). |
Edge Cases
| Situation | Behaviour |
|---|---|
targetDate is in the past at mount |
diff <= 0 immediately; isComplete = true; onComplete fires once; component renders the completion message. |
targetDate is invalid (e.g. "not-a-date") |
parseTargetDate returns an invalid Date; isNaN(target.getTime()) triggers; segments render zeros; interval cleared. |
| Tab is suspended (browser throttling) | On resume, the next tick recomputes from target - now β display heals automatically, no skew. |
| User changes system clock backwards | Display jumps forward (more time remaining); the recompute uses the system clock as ground truth. |
units prop is empty |
Loop renders nothing; the wrapper aria-live region remains, just with no segments. |
onComplete is called multiple times concurrently |
Guarded by if (!isComplete) β the callback fires exactly once even if the interval re-enters. |
| Component unmounts before completion | onDestroy clears the interval; no callback fires. |
hideOnComplete=true on completion |
`{#if !hideOnComplete |
Dependencies
- Svelte 5.x β
$state,$props,onMount,onDestroy. $lib/typesβ sharedCountdownProps,CountdownUnit,CountdownSegmentinterfaces.- Zero external dependencies β no animation library, native
setIntervalandDate.
File Structure
src/lib/components/Countdown.svelte # implementation
src/lib/components/Countdown.md # this file (rendered inside ComponentPageShell)
src/lib/components/Countdown.test.ts # vitest unit tests
src/routes/countdown/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
targetDate | Date | number | string | required | When to count down to. |
units | CountdownUnit[] | days/hours/min/sec | Which units appear. |
format | "cards" | "labels" | "compact" | "cards" | Display preset. |
showLabels | boolean | true | Show unit labels. |
separator | string | ":" | Separator for compact format. |
padZeros | boolean | true | Pad single digits with zeros. |
completedMessage | string | "Time's up!" | Message at completion. |
onComplete | () => void | undefined | Callback fired once when reaching zero. |
hideOnComplete | boolean | false | Hide entirely when complete. |