Roll text
Stacked text layers that roll vertically on hover — top slides up, bottom rises in. Playback latches until the animation finishes.
Installation
CLI
pnpm dlx shadcn@latest add https://animata.design/r/text/roll-text.json
The CLI installs roll-text.tsx and the co-located roll-text.css in one step.
Manual
Run the following command
mkdir -p animata/text && touch animata/text/roll-text.tsx animata/text/roll-text.cssPaste the code
@layer components {
.roll-text {
--roll-duration: 450ms;
--roll-duration-reduced: 150ms;
--roll-ease: cubic-bezier(0.77, 0, 0.175, 1);
--roll-line-height: 1;
font-kerning: none;
line-height: var(--roll-line-height);
}
.roll-text:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 3px;
}
.roll-text__track {
display: inline;
line-height: inherit;
white-space: pre;
}
/* inline-grid + overflow:hidden keeps the text baseline (from the sizer in
row 1) while clipping the translated stack. */
.roll-unit {
position: relative;
display: inline-grid;
overflow: hidden;
vertical-align: baseline;
font-kerning: none;
line-height: inherit;
}
.roll-unit__sizer,
.roll-unit__stack {
grid-area: 1 / 1;
line-height: inherit;
}
.roll-unit__sizer {
visibility: hidden;
pointer-events: none;
user-select: none;
white-space: pre;
}
.roll-unit__stack {
position: absolute;
inset-inline: 0;
top: 0;
display: flex;
flex-direction: column;
white-space: pre;
transform: translateY(0);
}
.roll-unit__line {
display: block;
line-height: inherit;
white-space: inherit;
}
@keyframes roll-stack-open {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
.roll-text--animating .roll-unit__stack {
animation: roll-stack-open var(--roll-unit-duration, var(--roll-duration)) var(--roll-ease)
var(--roll-delay, 0ms) forwards;
}
.roll-text--open:not(.roll-text--animating) .roll-unit__stack {
transform: translateY(-50%);
}
@media (prefers-reduced-motion: reduce) {
.roll-text--animating .roll-unit__stack {
animation-duration: var(--roll-duration-reduced);
}
}
}"use client";
import type React from "react";
import {
Fragment,
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import "./roll-text.css";
export type RollStagger = "none" | "word" | "character";
export interface RollTextProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
/** Label duplicated across the two stacked roll layers. */
text: string;
/**
* When true, the roll plays when the nearest `[data-roll-group]` or
* `group/roll` ancestor receives hover or focus. The span is not tabbable —
* put `href` / actions on the wrapping link or button.
* @default false
*/
groupHover?: boolean;
/**
* When true, hover and focus do not trigger the roll — use for active nav
* items or other states where motion should stay static.
* @default false
*/
disabled?: boolean;
/**
* Stagger the roll across words or characters. `none` animates the whole
* label at once.
* @default "none"
*/
stagger?: RollStagger;
/** Delay step between staggered units in ms. @default 32 */
staggerMs?: number;
/** Per-unit travel duration in ms. @default 250 */
durationMs?: number;
}
type RollPhase = "closed" | "animating" | "open";
type RollSegment = {
key: string;
value: string;
delay: number;
duration: number;
};
function staggerDelay(index: number, count: number, staggerMs: number): number {
if (count <= 1) return 0;
return index * staggerMs;
}
function segmentTiming(
index: number,
count: number,
staggerMs: number,
durationMs: number,
): { delay: number; duration: number } {
return {
delay: staggerDelay(index, count, staggerMs),
duration: durationMs,
};
}
function splitSegments(
text: string,
stagger: RollStagger,
staggerMs: number,
durationMs: number,
): RollSegment[] {
if (stagger === "none") {
return [{ key: "whole", value: text, delay: 0, duration: durationMs }];
}
if (stagger === "word") {
const words = text.trim().split(/\s+/);
return words.map((word, index) => {
const timing = segmentTiming(index, words.length, staggerMs, durationMs);
return {
key: `${index}-${word}`,
value: word,
...timing,
};
});
}
return [...text].map((char, index) => {
const timing = segmentTiming(index, text.length, staggerMs, durationMs);
return {
key: `${index}-${char}`,
value: char,
...timing,
};
});
}
const ROLL_GROUP_SELECTOR = "[data-roll-group], .group\\/roll";
function findRollGroup(node: HTMLElement | null) {
return node?.closest(ROLL_GROUP_SELECTOR) ?? null;
}
const RollUnit = memo(function RollUnit({
segment,
onStackAnimationEnd,
}: {
segment: RollSegment;
onStackAnimationEnd?: (event: React.AnimationEvent<HTMLSpanElement>) => void;
}) {
return (
<span
className="roll-unit"
style={
{
"--roll-delay": `${segment.delay}ms`,
"--roll-unit-duration": `${segment.duration}ms`,
} as React.CSSProperties
}
>
<span className="roll-unit__sizer" aria-hidden>
{segment.value}
</span>
<span className="roll-unit__stack" aria-hidden onAnimationEnd={onStackAnimationEnd}>
<span className="roll-unit__line">{segment.value}</span>
<span className="roll-unit__line">{segment.value}</span>
</span>
</span>
);
});
/**
* Decorative roll label — always a `span`. Wrap in `<a>`, `<Link>`, or `<button>`
* for navigation or actions.
*/
export default function RollText({
text,
groupHover = false,
disabled = false,
stagger = "none",
staggerMs = 32,
durationMs = 250,
className,
tabIndex,
style,
onMouseEnter,
onFocus,
onAnimationEnd,
...props
}: RollTextProps) {
const segments = useMemo(
() => splitSegments(text, stagger, staggerMs, durationMs),
[text, stagger, staggerMs, durationMs],
);
const rootRef = useRef<HTMLSpanElement>(null);
const phaseRef = useRef<RollPhase>("closed");
const disabledRef = useRef(disabled);
const segmentCountRef = useRef(segments.length);
const remainingRef = useRef(0);
const reducedMotionRef = useRef(false);
const [phase, setPhase] = useState<RollPhase>("closed");
const resolvedTabIndex = tabIndex ?? (groupHover ? undefined : 0);
segmentCountRef.current = segments.length;
disabledRef.current = disabled;
useEffect(() => {
phaseRef.current = phase;
}, [phase]);
useEffect(() => {
if (disabled && phase !== "closed") {
setPhase("closed");
}
}, [disabled, phase]);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotionRef.current = media.matches;
const onChange = (event: MediaQueryListEvent) => {
reducedMotionRef.current = event.matches;
};
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
const playOpen = useCallback(() => {
if (disabledRef.current || phaseRef.current === "animating") return;
if (reducedMotionRef.current) {
setPhase("open");
return;
}
remainingRef.current = segmentCountRef.current;
if (phaseRef.current === "open") {
setPhase("closed");
requestAnimationFrame(() => {
requestAnimationFrame(() => setPhase("animating"));
});
return;
}
setPhase("animating");
}, []);
useLayoutEffect(() => {
if (!groupHover || disabled) return;
const node = rootRef.current;
const group = findRollGroup(node);
if (!group) return;
group.addEventListener("mouseenter", playOpen);
group.addEventListener("focusin", playOpen);
return () => {
group.removeEventListener("mouseenter", playOpen);
group.removeEventListener("focusin", playOpen);
};
}, [groupHover, disabled, playOpen]);
const handleMouseEnter = (event: React.MouseEvent<HTMLSpanElement>) => {
onMouseEnter?.(event);
if (!groupHover && !disabled) playOpen();
};
const handleFocus = (event: React.FocusEvent<HTMLSpanElement>) => {
onFocus?.(event);
if (!groupHover && !disabled) playOpen();
};
const handleStackAnimationEnd = (event: React.AnimationEvent<HTMLSpanElement>) => {
onAnimationEnd?.(event);
if (phaseRef.current !== "animating") return;
remainingRef.current -= 1;
if (remainingRef.current <= 0) setPhase("open");
};
return (
<span
ref={rootRef}
tabIndex={resolvedTabIndex}
className={cn(
"roll-text relative inline-block cursor-default",
phase === "animating" && "roll-text--animating",
phase === "open" && "roll-text--open",
className,
)}
style={
{
...style,
"--roll-duration": `${durationMs}ms`,
} as React.CSSProperties
}
onMouseEnter={handleMouseEnter}
onFocus={handleFocus}
{...props}
>
<span className="sr-only">{text}</span>
<span className="roll-text__track select-none" aria-hidden>
{segments.map((segment, index) => (
<Fragment key={segment.key}>
{stagger === "word" && index > 0 ? " " : null}
<RollUnit segment={segment} onStackAnimationEnd={handleStackAnimationEnd} />
</Fragment>
))}
</span>
</span>
);
}Usage
import RollText from "@/animata/text/roll-text";
<RollText text="Hello world" className="font-(family-name:--font-display) text-4xl text-foreground" />Stagger by word or character:
<RollText text="Word by word" stagger="word" staggerMs={50} className="text-4xl text-foreground" />
<RollText text="Letters" stagger="character" staggerMs={32} className="text-4xl text-foreground" />RollText always renders a span. Put links and buttons on the parent — do not pass href to RollText.
Inside a link or card, use groupHover and mark the ancestor with data-roll-group. Add pointer-events-none on RollText so hover and focus stay on the parent (mouseenter does not bubble from child to parent).
<a href="/docs" data-roll-group className="group/roll block rounded-xl p-5 ring-1 ring-foreground/10">
<RollText
groupHover
text="Components"
stagger="character"
className="pointer-events-none text-2xl text-foreground"
/>
</a>Site header nav uses the same pattern: data-roll-group on the Link, groupHover + pointer-events-none on RollText, fixed color on RollText (not hover:text-* on the link), and disabled on the active item.
How it works
Markup
Each stagger unit is a .roll-unit — a hidden sizer sets width and height, with two absolutely positioned panels (.roll-panel-front and .roll-panel-back) stacked inside an overflow: hidden mask. Units flow inline inside .roll-text__track.
Motion
Both panels show the same glyph in the same color — set it on RollText via className (e.g. text-foreground). Avoid hover:text-* on the parent link; repainting mid-roll will jitter the animation. Closed: the front panel sits at translateY(0), the back panel waits below at translateY(150% + gap). On open, the front slides up and out while the back rises into place.
Keyframes and easing live in roll-text.css. The root sets --roll-duration, --roll-ease, --roll-travel, --roll-layer-gap, --roll-line-height, and --roll-duration-reduced. Per-unit stagger writes --roll-delay and --roll-unit-duration inline. font-kerning: none keeps per-character boxes from clipping descenders.
Playback
A small client state machine runs closed → animating → open.
- Hover or focus triggers
playOpen. If the pointer leaves mid-animation, units still finish — the open state latches. - Re-hover while open snaps closed, then replays on the next frame.
- Re-hover while
animatingis ignored. prefers-reduced-motion: reduceskips the roll and jumps toopen.
Completion is tracked per unit via animationend on each .roll-panel-front.
Group hover
With groupHover, listeners attach to the nearest [data-roll-group] or .group/roll ancestor (mouseenter and focusin). Prefer data-roll-group for reliable lookup; keep Tailwind group/roll when you need named group variants elsewhere.
Direct mouseenter / focus on the root still work when groupHover is false.
Stagger
Linear start delays (index × staggerMs) with a shared finish time — later units get a longer --roll-unit-duration so the wave overlaps and lands together.
Accessibility
RollText is a span — it can receive mouseenter and focus like any element. It is not focusable by default; standalone instances use tabIndex={0} so keyboard users can tab to the label and trigger the roll on focus.
The animated duplicate (.roll-text__track and roll units) is aria-hidden — decorative motion only. Screen readers read the real label from a single sr-only child. Do not put role="presentation" on the root; that would strip the label from the tree.
With groupHover, tabIndex is omitted — the wrapping link or button owns focus and activation. Pass tabIndex={-1} to force non-tabbable when standalone.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | Label copied into both stacked layers. |
groupHover | boolean | false | Also open when the nearest |
disabled | boolean | false | Skip the roll on hover and focus — use for active nav items or other static states. |
stagger | "none" | "word" | "character" | "none" | Animate the whole label at once, or one roll unit per word or character. |
staggerMs | number | 32 | Linear delay step between unit start times in milliseconds. |
durationMs | number | 250 | Base travel duration per unit in milliseconds. Staggered units extend so they finish together. |
className | string | — | Classes on the root — typography, color, cursor. |
Credits
Built by sudha