A React text effect where each character rolls into place. Accessible, customizable and styled with Tailwind CSS.
<RollingText
className="font-semibold font-serif text-4xl"
direction={direction}
duration={duration}
staggerMs={staggerMs}
spinCount={spinCount}
>
Rolling
</RollingText>Copy and paste the following code into your project.
"use client";
import {
type ComponentPropsWithoutRef,
type CSSProperties,
type ElementType,
useState,
} from "react";
import { cn } from "@/lib/utils";
const ROLL_EASE = "ease-[cubic-bezier(0.16,1,0.3,1)]";
const ROLLING_TEXT_CSS = `
@keyframes roll-left {
from {
translate: 0 0;
}
to {
translate: var(--roll-x) 0;
}
}
@keyframes roll-right {
from {
translate: var(--roll-x) 0;
}
to {
translate: 0 0;
}
}
.group\\/roll:hover .roll-hover-left {
animation: roll-left var(--roll-duration) cubic-bezier(0.16, 1, 0.3, 1)
var(--roll-delay) both;
}
.group\\/roll:hover .roll-hover-right {
animation: roll-right var(--roll-duration) cubic-bezier(0.16, 1, 0.3, 1)
var(--roll-delay) both;
}
@media (prefers-reduced-motion: reduce) {
.group\\/roll:hover .roll-hover-left,
.group\\/roll:hover .roll-hover-right {
animation: none;
}
}
`;
function RollingTextStyles() {
return (
// biome-ignore lint/suspicious/noUnknownAttribute: React 19 hoists and dedupes this style tag
<style href="rolling-text" precedence="default">
{ROLLING_TEXT_CSS}
</style>
);
}
type RollDirection = "left" | "right";
interface RollingTextOwnProps<T extends ElementType> {
as?: T;
children: string;
/**
* `"left"` scrolls the strip left so the next glyph enters from the right.
* `"right"` does the opposite. Stagger follows the same direction.
* @default "left"
*/
direction?: RollDirection;
/**
* How long one slot's roll lasts, in milliseconds.
* @default 1000
*/
duration?: number;
/**
* How many times the slot's own glyph is repeated in the reel.
* @default 8
*/
spinCount?: number;
/**
* Delay between each slot, in milliseconds.
* Slots start from the left when `direction` is `"left"`, and from the right when it is `"right"`.
* @default 0
*/
staggerMs?: number;
}
type RollingTextAnimationType =
| {
/**
* Delay before the roll starts, in milliseconds.
* Added on top of each slot's stagger.
* @default 0
*/
animationDelayMs?: number;
/**
* When true, the roll plays on hover instead of when the text mounts.
* @default false
*/
animateOnHover?: false;
}
| {
/**
* When true, the roll plays on hover instead of when the text mounts.
* @default false
*/
animateOnHover?: true;
/**
* Delay before the roll starts, in milliseconds.
* Added on top of each slot's stagger.
* @default 0
*/
animationDelayMs?: never;
};
type RollingTextProps<T extends ElementType = "span"> = RollingTextOwnProps<T> &
RollingTextAnimationType &
Omit<ComponentPropsWithoutRef<T>, keyof RollingTextOwnProps<T>>;
const segmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
const slotClassName = "inline-block h-[1lh] align-baseline whitespace-pre";
function splitGraphemes(text: string): string[] {
if (!segmenter) {
return Array.from(text);
}
return Array.from(segmenter.segment(text), ({ segment }) => segment);
}
function displayGlyph(glyph: string): string {
if (glyph === " ") {
return "\u00A0";
}
return glyph;
}
interface ReelCell {
glyph: string;
id: string;
}
function pushReelCell(reel: ReelCell[], glyph: string) {
reel.push({ glyph, id: `${reel.length}:${glyph}` });
}
function buildReel(
from: string,
to: string,
spinCount: number,
direction: RollDirection
): ReelCell[] {
if (from === to) {
return [{ glyph: to, id: `0:${to}` }];
}
const reel: ReelCell[] = [];
if (from !== "") {
pushReelCell(reel, from);
}
const repeatCount = Math.max(spinCount, 1);
for (let step = 0; step < repeatCount; step += 1) {
pushReelCell(reel, to);
}
if (direction === "right") {
reel.reverse();
}
return reel;
}
function getSlotDelayMs(
index: number,
slotCount: number,
direction: RollDirection,
staggerMs: number,
animationDelayMs: number
): number {
switch (direction) {
case "left":
return animationDelayMs + index * staggerMs;
case "right":
return animationDelayMs + (slotCount - 1 - index) * staggerMs;
default: {
const unreachable: never = direction;
return unreachable;
}
}
}
function getTravel(cellCount: number): string {
if (cellCount <= 1) {
return "0%";
}
return `${-((cellCount - 1) / cellCount) * 100}%`;
}
function stripMotionClassName(
direction: RollDirection,
animateOnHover: boolean
): string {
if (animateOnHover) {
switch (direction) {
case "left":
return "translate-x-(--roll-x) roll-hover-left";
case "right":
return "translate-x-0 roll-hover-right";
default: {
const unreachable: never = direction;
return unreachable;
}
}
}
switch (direction) {
case "left":
return "translate-x-(--roll-x) starting:translate-x-0";
case "right":
return "translate-x-0 starting:translate-x-(--roll-x)";
default: {
const unreachable: never = direction;
return unreachable;
}
}
}
interface CharSlotProps {
animateOnHover: boolean;
delayMs: number;
direction: RollDirection;
durationMs: number;
from: string;
spinCount: number;
to: string;
}
function CharSlot({
animateOnHover,
delayMs,
direction,
durationMs,
from,
spinCount,
to,
}: CharSlotProps) {
const glyph = displayGlyph(to);
if (from === to) {
return (
<span aria-hidden="true" className={slotClassName}>
{glyph}
</span>
);
}
const reel = buildReel(from, to, spinCount, direction);
const cellCount = reel.length;
const cellWidth = `${100 / cellCount}%`;
const stripStyle = {
"--roll-delay": `${delayMs}ms`,
"--roll-duration": `${durationMs}ms`,
width: `${cellCount * 100}%`,
} as CSSProperties;
return (
<span aria-hidden="true" className={cn(slotClassName, "relative")}>
<span className="invisible">{glyph}</span>
<span
className="absolute inset-0 overflow-hidden"
style={{ "--roll-x": getTravel(cellCount) } as CSSProperties}
>
<span
className={cn(
"flex h-full will-change-[translate]",
animateOnHover
? null
: "transition-[translate] delay-(--roll-delay) duration-(--roll-duration) motion-reduce:transition-none",
animateOnHover ? null : ROLL_EASE,
stripMotionClassName(direction, animateOnHover)
)}
key={`${from}\u0000${to}\u0000${direction}\u0000${spinCount}`}
style={stripStyle}
>
{reel.map((cell) => (
<span
className="block h-full min-w-0 shrink-0 overflow-hidden text-center"
key={cell.id}
style={{ width: cellWidth }}
>
{displayGlyph(cell.glyph)}
</span>
))}
</span>
</span>
</span>
);
}
function RollingText<T extends ElementType = "span">({
animateOnHover = false,
as,
className,
direction = "left",
duration = 1000,
animationDelayMs = 0,
spinCount = 8,
staggerMs = 0,
children,
...props
}: RollingTextProps<T>) {
const [snapshot, setSnapshot] = useState(() => ({
from: "",
to: children,
}));
if (staggerMs < 0) {
throw new Error("staggerMs must be greater than 0");
}
if (animationDelayMs < 0) {
throw new Error("animationDelayMs must be greater than 0");
}
if (children !== snapshot.to) {
setSnapshot({ from: snapshot.to, to: children });
}
const Tag: ElementType = as ?? "span";
const slots = buildSlots(
snapshot.from,
snapshot.to,
direction,
staggerMs,
animationDelayMs
);
return (
<Tag
{...props}
aria-label={children}
className={cn(
"inline whitespace-pre",
animateOnHover && "group/roll",
className
)}
>
<RollingTextStyles />
{slots.map((slot) => (
<CharSlot
animateOnHover={animateOnHover}
delayMs={slot.delayMs}
direction={direction}
durationMs={duration}
from={slot.from}
key={slot.key}
spinCount={spinCount}
to={slot.grapheme}
/>
))}
</Tag>
);
}
interface SlotModel {
delayMs: number;
from: string;
grapheme: string;
key: string;
}
function buildSlots(
fromText: string,
toText: string,
direction: RollDirection,
staggerMs: number,
animationDelayMs: number
): SlotModel[] {
const fromGraphemes = splitGraphemes(fromText);
const graphemes = splitGraphemes(toText);
const seen = new Map<string, number>();
return graphemes.map((grapheme, index) => {
const occurrence = (seen.get(grapheme) ?? 0) + 1;
seen.set(grapheme, occurrence);
return {
delayMs: getSlotDelayMs(
index,
graphemes.length,
direction,
staggerMs,
animationDelayMs
),
from: fromGraphemes[index] ?? "",
grapheme,
key: `${occurrence}:${grapheme}`,
};
});
}
export type { RollingTextProps };
export { RollingText };<RollingText className="font-semibold font-serif text-4xl">
Rolling
</RollingText>duration is how long one character rolls, in milliseconds. staggerMs delays each character along the roll direction. animationDelayMs waits before the first character starts. spinCount is how many times the destination glyph repeats in the reel.
<RollingText
animationDelayMs={300}
className="font-semibold font-serif text-4xl"
duration={800}
spinCount={6}
staggerMs={40}
>
Rolling
</RollingText>"left" scrolls the reel left, so the next glyph enters from the right. "right" does the opposite. Stagger follows the same direction.
<RollingText
className="font-semibold font-serif text-4xl"
direction="right"
>
Rolling
</RollingText>animateOnHover plays the roll when the pointer hovers the text. Leave animationDelayMs unset.
<RollingText
animateOnHover
className="font-semibold font-serif text-4xl"
>
Hover me
</RollingText><RollingText
as="h2"
className="font-semibold font-serif text-4xl"
>
Rolling
</RollingText>aria-label. Each character slot is aria-hidden.You can pass aria-* attributes through to the element it renders as.
<RollingText aria-labelledby="heading">
Rolling
</RollingText>It accepts all the props of the HTML element it renders as, plus the following:
| Prop | Type | Description |
|---|---|---|
children | string | The text to display. Changing it rolls from the previous value. |
direction | "left" | "right" | Scroll direction of the reel. Stagger follows the same direction. Default is "left". |
duration | number | How long one character rolls, in milliseconds. Default is 1000. |
spinCount | number | How many times the destination glyph repeats in the reel. Default is 8. |
staggerMs | number | Delay between each character, in milliseconds. Default is 0. |
animationDelayMs | number | Delay before the roll starts, in milliseconds. Added on top of each character's stagger. Default is 0. Omit when animateOnHover is set. |
animateOnHover | boolean | Play the roll on hover instead of when the text mounts. Default is false. |
as | React.ElementType | The HTML element to render as. Default is span. |