CountUp

Locale-aware number animation primitive.

Live demo

01

KPI grid Β· viewport-triggered

Four stats Β· quart easing Β· 1.8 s

42,857 Active users
Β£1,500,000 Revenue
99.9% Uptime
4.8β˜… Avg. rating

Hero stat Β· expo, flash

Single big number Β· expo easing Β· 2.4 s Β· flash on complete

Funded $250,000,000 across our portfolio

Locale-aware formatting

Same value, different glyphs.

en-GB €1,234,567.89
de-DE €1.234.567,89
fr-FR €1β€―234β€―567,89

Count-down Β· direction-agnostic

start=60 β†’ end=0 Β· linear Β· 6 s

Doors open in 0s

Live controls Β· manual trigger

All props bound to inputs Β· re-run with the button

Β£1,500,000

Implementation

02
CountUp.svelte
<script lang="ts">
  import CountUp from '$lib/components/CountUp.svelte';
</script>
​
<CountUp end={2300000} prefix="Β£" duration={1800} easing="quart" trigger="viewport" />

CountUp drives a single requestAnimationFrame loop. Each frame computes normalised progress (elapsed / duration), runs it through the chosen easing, and sets the displayed value. Three triggers β€” viewport, mount, manual β€” share the same core. The live value is aria-hidden; a sibling visually-hidden span carries the destination value so screen readers announce the end-state once.

Logic explainer

03

What Does It Do? (Plain English)

CountUp animates a number from a start value (default 0) up to an end value over a configurable duration, with an easing curve, formatted display (locale, decimals, prefix/suffix, thousands grouping), and an optional flash-on-complete cue. Trigger on viewport entry, on mount, or manually. Exposes run() and reset() so consumers can drive it imperatively.

Think of it as the "stat ticker" you see on marketing pages β€” "$2.3M raised" counting up the moment the section scrolls into view.

How It Works (Pseudo-Code)

state:
  current   = start          // resting value at SSR / pre-mount
  done      = false
  startTs   = null            // performance.now() at first rAF tick
  rafId     = null
  observer  = IntersectionObserver | null

derive:
  easeFn       = pickEasing(easing)
  formatOpts   = { locale, decimals, prefix, suffix, useGrouping }
  displayValue = formatNumber(current, formatOpts)   // visual
  finalLabel   = formatNumber(end, formatOpts)        // SR-only

helpers (pure, exported):
  pickEasing(name)           β†’ t-curve   (linear | quad | cubic | quart | expo)
  easeOutQuart(t)            β†’ 1 - (1-t)^4
  tickValue(start, end, t, easeFn)  β†’ start + (end - start) * easeFn(clamp(t, 0, 1))
  clampValue(v, start, end)  β†’ guard against rAF overshoot on the final tick
  formatNumber(value, opts)  β†’ Intl.NumberFormat with prefix/suffix
  isReducedMotion()          β†’ bool

step(now):
  if startTs null: startTs = now
  elapsed = now - startTs
  t       = clamp(elapsed / duration, 0, 1)   (or 1 if duration <= 0)
  v       = tickValue(start, end, t, easeFn)
  current = clampValue(v, start, end)
  if t < 1: rafId = requestAnimationFrame(step)
  else: current = end; done = true; rafId = null

run():
  cancelAnimationFrame(rafId); startTs = null
  done = false; current = start
  if SSR or reduced-motion or duration <= 0:
    current = end; done = true; return
  rafId = requestAnimationFrame(step)

reset():
  cancelAnimationFrame(rafId); rafId = null
  startTs = null; current = start; done = false

trigger gating:
  trigger = 'mount':    run() onMount
  trigger = 'viewport': IntersectionObserver(threshold) β†’ run() once β†’ disconnect
  trigger = 'manual':   consumer calls run() / reset() via bind

The Core Concept: Easing the Final Stretch

A linear count from 0 to 1,000,000 in 1.8s feels mechanical and unfinished β€” you reach 1,000,000 and the animation simply stops. Real "stat ticker" anims slow toward the end so the eye lands gracefully on the final value. CountUp's default is quart-out (1 βˆ’ (1 βˆ’ t)⁴):

  t (progress)   linear   quart-out (1 - (1-t)^4)
  0.0            0.000    0.0000
  0.1            0.100    0.3439
  0.2            0.200    0.5904
  0.3            0.300    0.7599
  0.5            0.500    0.9375
  0.7            0.700    0.9919
  0.9            0.900    0.9999
  1.0            1.000    1.0000

By the half-way mark you're already at 94 % of the final value; the back half of the timeline is dedicated to easing those last few percent in. This is exactly what makes the anim feel "finished" rather than "cut off".

tickValue(start, end, t, easeFn) computes the in-flight value:

v = start + (end - start) * easeFn(clamp(t, 0, 1))

The function is direction-agnostic β€” pass start = 100, end = 0 and it counts down with the same easing curve. clampValue guards against rAF overshoot: if the browser fires the final frame slightly past duration (timestamp drift), the displayed value still snaps to exactly end.

Available curves (all map t ∈ [0,1] β†’ progress ∈ [0,1]):

  linear   β†’  t                                  no easing, mechanical
  quad     β†’  1 - (1-t)Β²                          gentle ease-out
  cubic    β†’  1 - (1-t)Β³                          stronger ease-out
  quart    β†’  1 - (1-t)⁴   (default)              "marketing-stat" feel
  expo     β†’  1 - 2^(-10t)                        steepest ease-out, dramatic

Performance

  • One rAF loop while running. Cancelled the moment t === 1 and on every run() re-entry.
  • Reduced-motion users skip the rAF loop entirely (current = end; done = true); the resting value paints once.
  • formatNumber runs once per rAF tick β€” Intl.NumberFormat is cheap and modern engines cache the formatter.
  • The final value is exposed through a visually-hidden .countup-sr span carrying the formatted finalLabel. Screen readers announce the destination number once, not every rAF tick β€” otherwise users would hear "two thousand thirteen, two thousand seventeen, two thousand twenty-two..." spam.
  • Viewport trigger uses IntersectionObserver with threshold: 0.4 (40% visible) and disconnects after the first intersection β€” the observer is dormant for the rest of the page lifetime.

CSS Animation Strategy

The displayed number is plain text β€” no transform, no filter β€” so the count itself doesn't need CSS animation. The optional flash prop adds a 600 ms text-shadow pulse keyframe when done flips true:

@keyframes countup-flash {
  0%   { text-shadow: 0 0 0 transparent;       transform: translateY(0); }
  35%  { text-shadow: 0 0 14px rgba(125,211,252,.9); transform: translateY(-2px); }
  100% { text-shadow: 0 0 0 transparent;       transform: translateY(0); }
}
.countup-flash .countup-value { animation: countup-flash 600ms ease-out 1; }

@media (prefers-reduced-motion: reduce) {
  .countup-flash .countup-value { animation: none; }
}

Reduced-motion users get the static destination value with no flash β€” just like they get the static value with no count animation.

State Flow Diagram

  [mounted]   current = start  (paints resting value during SSR/hydration)
        β”‚
        β”‚  trigger = 'mount': run() immediately
        β”‚  trigger = 'viewport': observe; on intersection β†’ run() β†’ disconnect
        β”‚  trigger = 'manual': consumer calls run() via bind
        β–Ό
  [running]   rAF loop
        β”‚
        β”‚  reduced-motion / SSR / duration <= 0
        β”‚     ──────────────────────▢ [done]   current = end; done = true
        β”‚
        β”‚  every rAF tick:
        β”‚     elapsed = now - startTs
        β”‚     t       = clamp(elapsed / duration, 0, 1)
        β”‚     current = clampValue(tickValue(start, end, t, easeFn), start, end)
        β”‚
        β”‚  t === 1
        β–Ό
  [done]   current = end
        β”‚
        β”‚  flash && done β†’ CSS keyframe fires once (600 ms)
        β–Ό
  [resting]   stays at end until reset() or new run()

  on destroy: cancelAnimationFrame; observer.disconnect()

Props Reference

Prop Type Default Description
end number required Destination value.
start number 0 Initial value (also the resting value during SSR).
duration number 1800 Milliseconds for the count animation. 0 snaps instantly.
easing 'linear' | 'quad' | 'cubic' | 'quart' | 'expo' 'quart' Easing curve.
decimals number 0 Decimal places in the formatted output (clamped to 0..20).
prefix string '' Text prepended ('$', 'Β£', etc.).
suffix string '' Text appended ('%', 'M', etc.).
locale string 'en-GB' BCP-47 locale passed to Intl.NumberFormat.
useGrouping boolean true Insert thousands separators per locale.
trigger 'viewport' | 'mount' | 'manual' 'viewport' When to start counting.
threshold number 0.4 Visibility ratio that triggers the viewport mode (0..1).
flash boolean false Run a 600 ms text-shadow pulse on completion.
size 'sm' | 'md' | 'lg' | 'xl' 'md' Type scale.
class string '' Extra classes on the wrapper.

Exported imperative API: run(): void, reset(): void β€” bind the component instance to call these.

Edge Cases

Situation Behaviour
duration <= 0 run() snaps current = end; done = true without entering the rAF loop.
start > end Counts down. Easing applies in the same direction; clampValue keeps the displayed value within [end, start].
User has prefers-reduced-motion: reduce run() snaps to end; no rAF, no flash.
trigger = 'viewport' and IntersectionObserver unsupported The observer block is skipped; consumer must call run() manually or use trigger='mount'.
Component re-mounts after completion Fresh state β€” current initialises to start again; trigger logic re-fires.
run() called while already running Existing rAF cancelled; startTs reset; new run starts cleanly from start.
reset() called mid-flight rAF cancelled; current = start; done = false.
Locale unknown to Intl Intl.NumberFormat substitutes the runtime default β€” no throw.
decimals outside 0..20 Clamped to the valid range before being passed to Intl.

Dependencies

  • Svelte 5.x β€” $state, $derived, $props, onMount, onDestroy, module-script exports.
  • Intl.NumberFormat (native) β€” locale-aware formatting.
  • IntersectionObserver (native) β€” viewport trigger; component falls back gracefully when missing.
  • Zero external dependencies otherwise β€” no GSAP, no anime.js.

File Structure

src/lib/components/CountUp.svelte    # implementation
src/lib/components/CountUp.md        # this file (rendered inside ComponentPageShell)
src/lib/components/CountUp.test.ts   # vitest unit tests for the pure helpers
src/routes/countup/+page.svelte      # demo page

API

04
PropTypeDefaultDescription
endnumberrequiredFinal value to settle on.
startnumber0Starting value (>end counts down).
durationnumber1800Animation duration in ms.
easing"linear" | "quad" | "cubic" | "quart" | "expo""quart"Easing curve.
trigger"viewport" | "mount" | "manual""viewport"When the run starts.
thresholdnumber0.4IntersectionObserver visibility ratio for the 'viewport' trigger.
decimalsnumber0Decimal places (0–20).
prefix / suffixstring""Wraps the formatted number.
localestringbrowser defaultIntl.NumberFormat locale.
useGroupingbooleantrueThousand-grouping separators.
flashbooleanfalseBrief highlight on complete.
size"sm" | "md" | "lg" | "xl""md"Type size preset.