TextRoll
Single-line text that rolls to its new value per grapheme, odometer-style, whenever the value prop changes — while the real, unsplit text stays selectable and readable
Preview
<script lang="ts">
import { TextRoll } from "fancy-ui-svelte";
import { Switch } from "fancy-ui-svelte";
let yearly = $state(false);
// Same digit count either way ("$50" / "$40") on purpose — it's what makes
// the roll visible as "only the changed digit moves": the "$" cell and the
// trailing "0" cell keep their DOM node (same key, same grapheme), only
// the middle digit's cell actually rolls.
let price = $derived(yearly ? "$40" : "$50");
</script>
<div class="flex w-full flex-col items-center gap-6 p-6">
<div
class="bg-card border-border flex w-full max-w-xs flex-col items-center gap-1 rounded-xl border p-6"
>
<p class="text-muted-foreground text-sm">Pro plan</p>
<p class="text-foreground flex items-baseline gap-1 text-4xl font-semibold">
<!-- tabular: locks digit advance width so the two internal layers never
drift apart. live="polite": a final price after a toggle is exactly
the kind of change worth announcing (unlike a per-second ticker). -->
<TextRoll value={price} tabular live="polite" />
<span class="text-muted-foreground text-base font-normal">/mo</span>
</p>
<p class="text-muted-foreground text-xs">
{yearly ? "Billed annually" : "Billed monthly"}
</p>
</div>
<Switch bind:checked={yearly}>Bill yearly, save 20%</Switch>
</div>Installation
Usage
<script lang="ts">
import { TextRoll } from 'fancy-ui-svelte';
</script>
<TextRoll />Examples
Basic Usage
<script lang="ts">
import { TextRoll } from "$lib/fancy-ui/text-roll";
import { Switch } from "$lib/fancy-ui/switch";
let yearly = $state(false);
// Same digit count either way ("$50" / "$40") on purpose — it's what makes
// the roll visible as "only the changed digit moves": the "$" cell and the
// trailing "0" cell keep their DOM node (same key, same grapheme), only
// the middle digit's cell actually rolls.
let price = $derived(yearly ? "$40" : "$50");
</script>
<div class="flex w-full flex-col items-center gap-6 p-6">
<div
class="bg-card border-border flex w-full max-w-xs flex-col items-center gap-1 rounded-xl border p-6"
>
<p class="text-muted-foreground text-sm">Pro plan</p>
<p class="text-foreground flex items-baseline gap-1 text-4xl font-semibold">
<!-- tabular: locks digit advance width so the two internal layers never
drift apart. live="polite": a final price after a toggle is exactly
the kind of change worth announcing (unlike a per-second ticker). -->
<TextRoll value={price} tabular live="polite" />
<span class="text-muted-foreground text-base font-normal">/mo</span>
</p>
<p class="text-muted-foreground text-xs">
{yearly ? "Billed annually" : "Billed monthly"}
</p>
</div>
<Switch bind:checked={yearly}>Bill yearly, save 20%</Switch>
</div>Auto Ticker
An auto-incrementing counter, only the changed digits roll.
<script lang="ts">
import { TextRoll } from "$lib/fancy-ui/text-roll";
let count = $state(1204);
// A counter that ticks forever is the shape WCAG 2.2.2 (Pause, Stop, Hide)
// is about, so the demo holds still under `prefers-reduced-motion: reduce`
// — there is no roll left to demonstrate there anyway.
$effect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = setInterval(() => {
count += Math.floor(Math.random() * 3) + 1;
}, 1000);
return () => clearInterval(id);
});
</script>
<div class="flex w-full flex-col items-center gap-2 p-6">
<p class="text-muted-foreground text-sm">Downloads today</p>
<!-- No `live` here on purpose — a live region firing every second on a
fast-moving counter is worse than silence (see the component's own
Accessibility notes); `direction="auto"` (the default) already resolves
to "up" for a value that only ever increases. -->
<TextRoll value={String(count)} tabular class="text-foreground text-5xl font-bold" />
</div>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value * | string | - | The text to display; a change after mount triggers a per-grapheme roll to the new value |
direction | "auto" | "up" | "down" | "auto" | Which way the cells travel; "auto" compares the trimmed old and new values as numbers and falls back to "up" for a tie, a non-numeric change, or an empty side |
duration | number | 300 | Roll duration in ms, collapsed to 0 under reduced motion |
stagger | number | 15 | Per-cell stagger step in ms, compressed to a 200ms total spread |
from | "first" | "last" | "center" | number | "first" | Stagger origin |
tabular | boolean | false | font-variant-numeric: tabular-nums on both layers, to keep digit width locked |
live | "off" | "polite" | "assertive" | "off" | Live-region role/aria-live pair on the real text layer; off by default |
class | string | - | Additional CSS classes |
ref | HTMLSpanElement | null | null | Bindable reference to the root element |
Links
Related components
BlurReveal
Scroll-triggered reveal that unblurs and slides each direct child into place as the container enters the viewport, staggering up to 10 children via CSS nth-child delays and respecting reduced motion
BoxReveal
Reveal animation where a solid color box slides left to right across content on viewport entry, unveiling it as it fades up into place underneath, triggered once via IntersectionObserver
ColourfulText
Per-character text animation where each glyph transitions to a new color from a shuffled palette, staggered across characters and reshuffling automatically every 5 seconds using pure CSS transitions