Skip to content
Docs
Roll text

Roll text

Stacked text layers that roll vertically on hover — top slides up, bottom rises in. Playback latches until the animation finishes.

newrequires interactionhover
Loading...

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

Paste 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 closedanimatingopen.

  • 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 animating is ignored.
  • prefers-reduced-motion: reduce skips the roll and jumps to open.

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

Component props
PropTypeDefaultDescription
textstring

Label copied into both stacked layers.

groupHoverbooleanfalse

Also open when the nearest [data-roll-group] or group/roll ancestor is hovered or focused. Pair with pointer-events-none on the label inside links.

disabledbooleanfalse

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.

staggerMsnumber32

Linear delay step between unit start times in milliseconds.

durationMsnumber250

Base travel duration per unit in milliseconds. Staggered units extend so they finish together.

classNamestring

Classes on the root — typography, color, cursor.

Credits

Built by sudha