Svelte 5 component

Numbers that roll, not jump.

RollingNumber animates every changed glyph of a number or a string with springs, blur and clipping. Drop it in wherever a value changes: prices, counters, clocks, scores, status labels.

npm install svelte-rolling-number
1234

Features

  • Per-glyph transitions

    Only the characters that change move, in numbers, words and emoji alike. Numbers stay aligned on the decimal separator.

  • Spring-driven motion

    Roll, scale, fade and blur run on springs. Tune duration, bounce and travel; the character of the motion stays intact.

  • Accessible by default

    Screen readers get the plain value. prefers-reduced-motion disables animation automatically.

  • SSR friendly

    The value renders as plain text on the server and before hydration, so there is no layout shift or empty box.

Usage

Import RollingNumber and pass it a value. Strings and numbers both work. Whenever the value changes, the component diffs the old and new text per glyph and animates only what changed.

  1. Install the package from npm(opens in a new tab). It needs Svelte ^5.7.0.
  2. Render <RollingNumber value={…} />.
  3. Style it with plain CSS, which it inherits like any text, and tune the motion with props such as duration and bounce.
Full documentation →
Counter.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	let count = $state(0);
</script>

<RollingNumber value={count} style="font-size: 48px" monospacedDigits />

<button onclick={() => count++}>Increment</button>

Examples

Each demo below runs the code next to it.

$184.32

Price ticker

Format with Intl.NumberFormat and fade the colour as the price moves, in step with the glyphs. Direction follows the value.

PriceTicker.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	// Shared by the glyphs and the colour, so both settle together.
	const duration = 420;

	let price = $state(184.32);
	let previous = $state(184.32);

	$effect(() => {
		const id = setInterval(() => {
			previous = price;
			price = Math.max(1, price + (Math.random() - 0.5) * 4);
		}, 1600);
		return () => clearInterval(id);
	});

	const formatter = new Intl.NumberFormat("en-US", {
		style: "currency",
		currency: "USD",
	});
</script>

<RollingNumber
	value={formatter.format(price)}
	{duration}
	style="font-size: 40px; font-weight: 600; color: {price >= previous ? '#16a34a' : '#dc2626'}; transition: color {duration}ms"
	monospacedDigits
/>
Save

Status label

Any string works, not just numbers. Plain text is matched from the start, so the shared "Sav" stays put and only the ending rolls.

Status.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	const steps = ["Save", "Saving…", "Saved"];

	let index = $state(0);

	$effect(() => {
		const id = setInterval(() => {
			index = (index + 1) % steps.length;
		}, 1400);
		return () => clearInterval(id);
	});
</script>

<!-- Text has no numeric order, so pin the direction. -->
<RollingNumber value={steps[index]} direction="up" style="font-size: 40px; font-weight: 600" />
12:13:21

Clock

A time string is not a number, so the direction is pinned to always roll up.

Clock.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	const format = (date: Date) =>
		date.toLocaleTimeString("en-GB", { hour12: false });

	let time = $state(format(new Date()));

	$effect(() => {
		const id = setInterval(() => (time = format(new Date())), 1000);
		return () => clearInterval(id);
	});
</script>

<!-- A clock is not a number, so pin the direction. -->
<RollingNumber value={time} direction="up" style="font-size: 40px" monospacedDigits />
10

Countdown

No bounce, no scale and a full line of travel give a mechanical, flip-counter feel.

Countdown.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	let seconds = $state(10);

	$effect(() => {
		const id = setInterval(() => {
			seconds = seconds === 0 ? 10 : seconds - 1;
		}, 1000);
		return () => clearInterval(id);
	});
</script>

<RollingNumber
	value={seconds}
	direction="down"
	bounce={0}
	travel={1}
	enterScale={1}
	style="font-size: 56px; font-weight: 700"
	monospacedDigits
/>
☀️ Sunny

Emoji

Glyphs are split into grapheme clusters, so an emoji built from several code points rolls as one piece and never breaks apart mid-transition.

Weather.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	const forecast = ["☀️ Sunny", "⛅ Cloudy", "🌧️ Rainy", "❄️ Snowy"];

	let index = $state(0);

	$effect(() => {
		const id = setInterval(() => {
			index = (index + 1) % forecast.length;
		}, 1600);
		return () => clearInterval(id);
	});
</script>

<!-- Each emoji is one glyph, even when it is several code points. -->
<RollingNumber value={forecast[index]} direction="up" style="font-size: 40px; font-weight: 600" />

Right now, 97 people are reading this page, and the sentence makes room as the count grows.

Inline in a sentence

RollingNumber renders inline, inherits the surrounding font and sits on the text baseline. When the value gains a digit, the width animates and the rest of the line follows.

Inline.svelte
<script lang="ts">
	import { RollingNumber } from "svelte-rolling-number";

	let readers = $state(97);

	$effect(() => {
		const id = setInterval(() => {
			// Hover around 100 so the count keeps gaining and losing a digit.
			readers += readers < 100 ? 2 : Math.random() < 0.5 ? -3 : 1;
		}, 1500);
		return () => clearInterval(id);
	});
</script>

<!-- Inherits the paragraph's font and sits on its baseline. -->
<p>
	Right now, <RollingNumber value={readers} style="font-weight: 600" /> people are reading this page,
	and the sentence makes room as the count grows.
</p>

Playground

Type any value, or step it with the buttons, then tune the transition props. The code below updates as you go.

2,048
direction
Props
<RollingNumber
	value="2,048"
	monospacedDigits
/>

Ready to make your numbers move?

Every prop, type and default is covered in the docs.

Read the docs