# 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.

- Package: `fancy-ui-svelte` (Svelte 5 + Tailwind CSS v4)
- Category: Text & Typography
- Tags: text, animation, roll, counter, micro-interaction
- Types: `TextRollProps`
- Docs: https://fancy-ui.rama.app/docs/components/text-roll
- Source: https://github.com/RamaHerbin/fancy-ui/tree/main/src/lib/fancy-ui/text-roll

## Import

```svelte
<script lang="ts">
  import { TextRoll } from 'fancy-ui-svelte';
</script>
```

Setup (once per project): `npm install fancy-ui-svelte`, then add `@import "fancy-ui-svelte/tailwind.css";` after `@import "tailwindcss";` in your main CSS file. Full guide: https://fancy-ui.rama.app/llms.txt

React: `import { TextRoll } from 'fancy-ui-react';` (`class` becomes `className`, snippets become `ReactNode`, DOM callbacks are camel-cased; exact names on https://fancy-ui.rama.app/docs/components/react/text-roll.md).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `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 |

## Examples

### Basic Usage

```svelte
<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>
```

### Auto Ticker

An auto-incrementing counter, only the changed digits roll.

```svelte
<script lang="ts">
	import { TextRoll } from "fancy-ui-svelte";

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