Countdown

Animated timer for deadlines and launches.

Live demo

01

Each 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
Countdown.svelte
<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

03

What 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 setInterval at 1 Hz. Cheap.
  • Every tick recomputes from scratch; no accumulator drift.
  • previousValues is a tiny Record<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.
  • setInterval is cleared in onDestroy β€” no orphan timer after navigation.
  • Invalid targetDate clears 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: clearInterval

Props 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 β€” shared CountdownProps, CountdownUnit, CountdownSegment interfaces.
  • Zero external dependencies β€” no animation library, native setInterval and Date.

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 page

API

04
PropTypeDefaultDescription
targetDateDate | number | stringrequiredWhen to count down to.
unitsCountdownUnit[]days/hours/min/secWhich units appear.
format"cards" | "labels" | "compact""cards"Display preset.
showLabelsbooleantrueShow unit labels.
separatorstring":"Separator for compact format.
padZerosbooleantruePad single digits with zeros.
completedMessagestring"Time's up!"Message at completion.
onComplete() => voidundefinedCallback fired once when reaching zero.
hideOnCompletebooleanfalseHide entirely when complete.