Rolling Text
Each character is its own little drum. When the roll plays, the drum spins through a short ramp of random glyphs and then stops on the real letter, staggered slightly from its neighbour, so a word assembles the way a departure board does.
The ramp is generated from the text itself by default: the unique characters in the string become the pool the intermediate glyphs are drawn from, which keeps the motion inside the same alphabet as the final word.
Usage
Pass the text as text. It rolls once on mount by default.
Split-flap headline
Ship something
<template>
<div class="flex w-full flex-col items-center gap-8 p-10 text-center">
<div>
<p class="text-xs font-medium tracking-wide text-muted uppercase">
Split-flap headline
</p>
<p class="mt-3 text-4xl font-semibold text-highlighted">
<MRollingText text="Ship something" />
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-3 text-sm text-muted">
<span>
<MRollingText
text="fast"
class="font-medium text-primary"
/>
</span>
<span>
<MRollingText
text="readable"
class="font-medium text-highlighted"
/>
</span>
<span>
<MRollingText
text="accessible"
class="font-medium text-highlighted"
/>
</span>
</div>
</div>
</template>
A character that has not been armed yet shows its final glyph, so trigger never
leaves the line half-spun or showing random letters before the first play.
Customize
trigger decides what starts a roll: mount, inView, hover or manual. With
manual, flip playing to true to replay; with hover, moving the pointer over
the text replays it on a fine-pointer device.
direction flips the drum, glyphs replaces the ramp alphabet, and duration,
stagger and rampLength tune the rhythm.
Hover to replay
Hover me
Rolls down
Downwards
Custom glyph ramp
012345
Manual trigger
On demand
<template>
<div class="grid w-full gap-4 p-6 sm:grid-cols-2">
<div class="rounded-xl border border-default bg-elevated/40 p-6">
<p class="text-xs font-medium tracking-wide text-muted uppercase">
Hover to replay
</p>
<p class="mt-3 text-2xl font-semibold text-highlighted">
<MRollingText
:ramp-length="6"
text="Hover me"
trigger="hover"
/>
</p>
</div>
<div class="rounded-xl border border-default bg-elevated/40 p-6">
<p class="text-xs font-medium tracking-wide text-muted uppercase">
Rolls down
</p>
<p class="mt-3 text-2xl font-semibold text-highlighted">
<MRollingText
:duration="700"
direction="down"
text="Downwards"
/>
</p>
</div>
<div class="rounded-xl border border-default bg-elevated/40 p-6">
<p class="text-xs font-medium tracking-wide text-muted uppercase">
Custom glyph ramp
</p>
<p class="mt-3 text-2xl font-semibold text-highlighted">
<MRollingText
:ramp-length="8"
glyphs="0123456789"
text="012345"
/>
</p>
</div>
<div class="rounded-xl border border-default bg-elevated/40 p-6">
<p class="text-xs font-medium tracking-wide text-muted uppercase">
Manual trigger
</p>
<p class="mt-3 text-2xl font-semibold text-highlighted">
<MRollingText
:playing="playing"
text="On demand"
trigger="manual"
/>
</p>
<UButton
class="mt-4"
color="neutral"
size="sm"
variant="subtle"
@click="replay"
>
Replay
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
const playing = ref(false)
async function replay() {
playing.value = false
await nextTick()
playing.value = true
}
</script>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | string | - | Text to render. Split into graphemes before rolling. |
class | string | - | Extra classes for the root element. |
direction | 'up' | 'down' | 'up' | Direction the glyph column rolls in before landing. |
duration | number | 500 | Roll duration per character, in milliseconds. |
glyphs | string | - | Ramp alphabet cycled through before landing on the target character. Defaults
to the unique characters found in text. |
playing | boolean | false | Plays the roll when trigger is manual and this flips to true. |
rampLength | number | 4 | Number of intermediate glyphs shown before the target lands. |
stagger | number | 40 | Per-character stagger, in milliseconds. |
trigger | 'mount' | 'inView' | 'hover' | 'manual' | 'mount' | What starts the roll. |
ui | object | - | Per-slot classes merged over the theme defaults. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.rollingText in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | inline-block |
srOnly | - | sr-only |
space | - | inline-block whitespace-pre |
char | - | inline-block h-[1em] overflow-hidden align-bottom leading-none |
column | - | flex flex-col |
glyph | - | block h-[1em] leading-none |
Accessibility
- The drum glyphs are decorative: every one is
aria-hidden="true"and a visually hidden copy of the fulltextfills thesrOnlyslot, so assistive technology reads the word once instead of reading the ramp. - The root is a plain
span, so the component inherits the semantics of whatever heading, paragraph or link it sits inside. - Under
prefers-reduced-motion: reducethe keyframes are disabled in CSS and the columns sit on their final glyph. The text is always fully readable, including before amanualorhovertrigger fires. trigger="hover"is an enhancement only. The same text is present and legible for keyboard and touch users without replaying it.
Performance
- The roll is CSS keyframes over
translateY, not a JavaScript animation loop. Each column carries its timing in--rt-durationand--rt-delaycustom properties, so a replay is a remount rather than a per-frame update. - Glyph columns are
will-change-free and stay on the compositor: onlytransformchanges. - The ramp is built from a seeded PRNG, so the server and the client produce the same glyphs on the first render and hydration never mismatches.
- Each character renders one column with
rampLength + 1glyphs, so keeptextto a word or short phrase rather than a paragraph.