Documentation

Everything you need to use RollingNumber, from installation to the full prop reference.

Getting started

Introduction

svelte-rolling-number provides a single Svelte 5 component, RollingNumber. It renders a line of text and, whenever its value changes, animates each glyph that changed: departing glyphs roll out, scale down, blur and fade; arriving glyphs do the reverse. Unchanged glyphs slide into their new positions.

The effect is inspired by SwiftUI's ContentTransition.numericText(value:)(opens in a new tab), brought to the web. The implementation is based on the web renderer from expo-content-transition(opens in a new tab), with a Svelte-native API.

Installation

Install the package from npm(opens in a new tab) with your package manager of choice. It requires Svelte ^5.7.0.

npm install svelte-rolling-number

Quick start

Import the component and pass it a value. Every other prop is optional.

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>

The first value renders without animation. Every later change to value triggers a transition.

Guides

Formatting values

RollingNumber does not format numbers itself. It animates whatever string it receives, so format with Intl.NumberFormat, toFixed or anything else before passing the value in. Passing a number is the same as passing String(value).

Glyphs are aligned around the decimal separator, so a change in the fractional part never shifts the whole part. For locales that use a comma, set decimalSeparator:

Price.svelte
<script lang="ts">
	const euro = new Intl.NumberFormat("de-DE", {
		style: "currency",
		currency: "EUR",
	});

	let amount = $state(1299.5);
</script>

<!-- "1.299,50 €": align around the comma, not the dot. -->
<RollingNumber value={euro.format(amount)} decimalSeparator="," monospacedDigits />

Add monospacedDigits whenever the value is a number. Without it, a narrow 1 turning into a wide 8 pushes every following glyph sideways.

Animating text

value can be any string, not just a number. Words, labels and emoji animate glyph by glyph, the same way digits do:

SaveButton.svelte
<!-- Both labels start with "Sav", so only the ending rolls. -->
<RollingNumber value={saving ? "Saving…" : "Saved"} direction="up" />

How old and new glyphs are paired depends on whether the value contains a digit:

  • Plain text is matched from the start. A glyph that stays the same at the same position doesn't move, so Save → Saved only rolls in the d.
  • Text around a number is anchored to the number: text before it is matched from the start, text after it from the end. 3 items → 12 items keeps items in place while the count changes.

Matching is by position, not by searching for the text the two values share. Adding or removing glyphs anywhere except the end shifts everything after them, so all of those glyphs roll: Saved → Unsaved animates every glyph. Text works best when the values share their start, like a label that moves through stages.

Each grapheme cluster is one glyph. An emoji, a flag or a letter with combining accents rolls as a single piece, even when it is made of several code points.

Text has no numeric order, so direction="auto" can't tell which way to roll and keeps the direction of the last change. Pin it to "up" or "down". monospacedDigits only affects digits, so it has no effect on letters.

Direction

With the default direction="auto", the component parses the old and new values as numbers and rolls glyphs up when the value grows and down when it shrinks.

Content that does not read as a number, such as a clock, a date or a status label, should pin the direction with "up" or "down". So should a countdown that must always tick one way:

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
/>

Tuning the motion

These props shape the transition. They combine freely:

  • duration scales every spring, keeping the feel of the motion intact.
  • bounce controls overshoot of the roll. Opacity and position never bounce.
  • travel sets how far glyphs roll, relative to the line height.
  • enterScale sets the size glyphs arrive at and leave with.
  • blurIntensity and maxBlurRadius control the motion blur.
Tuned.svelte
<RollingNumber
	value={value}
	duration={700}
	bounce={0.7}
	travel={0.6}
	enterScale={0.2}
	blurIntensity={3}
	maxBlurRadius={12}
/>

Blur is the most expensive part of a transition. When many glyphs change at once, or when you raise blurIntensity on large text, set maxBlurRadius to cap the cost. The playground on the home page lets you try every value live.

Typography

There are no typography props. Like any inline text, the component inherits font, size, weight, style, letter spacing, colour and font features from the surrounding CSS, and glyphs are measured in exactly that typography.

Distance.svelte
<p class="distance">
	<RollingNumber value="42,195 km" />
</p>

<style>
	.distance {
		font: italic 700 32px Georgia, serif;
		letter-spacing: -0.5px;
		color: rebeccapurple;
	}
</style>

To style one instance directly, pass class or style; both land on the host element. When the inherited typography changes later, from a theme switch, a media query or a web font finishing loading, glyphs are re-measured and laid out again without animating.

Disabling animation

Set animated={false} to apply changes instantly. The prop is reactive, so you can turn animation on only when it is meaningful, for example after the first user interaction:

<!-- Only animate after the user interacts. -->
<RollingNumber value={total} animated={hasInteracted} />

Accessibility

  • The current value is real text inside the host, transparent behind the animated glyphs. Screen readers read it once, in line with the surrounding content, instead of glyph by glyph, and find in page, translation and copy see the value too.
  • When the user has prefers-reduced-motion enabled, animation is turned off regardless of the animated prop.
  • Value changes are not announced automatically. If an update matters to the user, pass role="status" to make the component a polite live region, or wrap it in an element with aria-live="polite".

Server rendering

During server rendering and before hydration, the component renders the value as plain text in the inherited typography. When the glyph layer mounts, that copy stays in the layout, hidden, so the host keeps its size and text baseline when it sits inline. There is no layout shift and no empty box.

API reference

Content props

value string | number

The text to display. Required. Measuring, diffing and animating all happen per glyph.

alignment "start" | "center" | "end"

Horizontal alignment of the line inside the component's bounds.

Default: "start"

decimalSeparator string

The character separating the whole and fractional parts. Glyphs are aligned around it, so 123.45 → 123.46 only animates the final digit. Only the first character is used.

Default: "."

monospacedDigits boolean

Adds font-variant-numeric: tabular-nums so unchanged digits don't shift sideways when a neighbour changes. Unset, font-variant-numeric is inherited.

Default: false

Transition props

direction "auto" | "up" | "down"

Which way glyphs travel. auto rolls up when the numeric value grows and down when it shrinks.

Default: "auto"

duration number

Nominal transition duration in milliseconds. Scales every internal spring, so the character of the motion is preserved.

Default: 420

bounce number

How far the vertical roll overshoots before settling. Clamped to 0–0.95; 0 arrives dead straight.

Default: 0.46

enterScale number

Size a glyph starts at when arriving and shrinks to when leaving. Clamped to 0–2; 1 disables scaling.

Default: 0.4

travel number

How far a glyph rolls, as a fraction of the line height. Clamped to 0–3; 0 leaves only scale and fade.

Default: 0.333

blurIntensity number

Blurs each glyph in proportion to how far through its transition it is, multiplying radii derived from the line height. Clamped to 0–8; 0 turns blur off.

Default: 1

maxBlurRadius number

Ceiling on the blur radius in CSS pixels. Caps the cost of a transition when many glyphs change at once.

Default: unbounded

clip boolean

Clips each glyph to the measured line box with a feathered edge, so rolling glyphs don't overlap surrounding content.

Default: true

animated boolean

Set to false to apply values instantly. Also treated as false while prefers-reduced-motion is enabled.

Default: true

HTML attributes

Every other attribute is forwarded to the host <span>, including class, style, id, data-* and event handlers. role and children are reserved by the component, and aria-label is always set to the current value.

<RollingNumber
	value={score}
	class="score"
	style="vertical-align: middle"
	data-testid="score"
/>

The host is an inline-block with an explicit width and height that follow the content, so it can sit inside running text or a flex layout.

Types

All prop types are exported for use in wrapper components:

types.ts
import type {
	RollingNumberProps,
	RollingNumberTransitionProps,
	RollingNumberAlignment, // "start" | "center" | "end"
	RollingNumberDirection, // "auto" | "up" | "down"
} from "svelte-rolling-number";