Text FXAdvanced Text Animationsno dependencies

RollingText

Link or button label whose characters roll up on hover and focus to reveal a recolored duplicate underneath, staggered per character with GSAP-style eases; an optional auto-play loop demonstrates the roll without interaction.

Preview

Explore the work

Customize

8 props
0.025
0.55
56

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add rolling-text

Writes components/ui/rolling-text.tsx and components/styles/rolling-text.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { RollingText } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <RollingText />6  );7}

Props

PropTypeDefault
text

Label text; every character rolls up to reveal its duplicate.

string'Explore the work'
href

When set, renders an anchor pointing here.

stringundefined
target

Anchor target (only used with href).

stringundefined
onClick

When set (and no href), renders a button that calls this on click.

(event: React.MouseEvent<HTMLElement>) => voidundefined
stagger

Delay between consecutive characters in seconds.

number0.025
staggerFrom

Where the stagger wave starts from.

"start" | "center" | "end" | "edges" | "random"'start'
duration

Roll duration per character in seconds.

number0.55
ease

GSAP-style ease name (sampled into a CSS linear() easing).

"power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out"'power3.out'
color

Resting text color (defaults to the theme foreground).

stringundefined
hoverColor

Color of the duplicate revealed on hover/focus.

string'#a78bfa'
fontSize

Font size in pixels.

number56
autoPlay

Automatically roll back and forth as a demo loop (paused off-screen).

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

Accessibility

Renders a real <a> when href is set, a <button type="button"> when onClick is set, otherwise a <span>. The accessible name comes from a single visually hidden copy of the text; both visual character stacks are aria-hidden. Focus triggers the same roll as hover and shows a 2px :focus-visible outline. Under prefers-reduced-motion the auto-play loop is disabled and the roll transition is removed. The loop pauses off-screen.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/rolling-text.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/rolling-text.module.css";5 6export type RollingTextEase =7  "power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out";8 9export type RollingTextStaggerFrom = "start" | "center" | "end" | "edges" | "random";10 11export interface RollingTextProps {12  /** Label text; every character rolls up to reveal its duplicate */13  text?: string;14  /** When set, renders an anchor pointing here */15  href?: string;16  /** Anchor target (only used with href) */17  target?: string;18  /** When set (and no href), renders a button that calls this on click */19  onClick?: (event: React.MouseEvent<HTMLElement>) => void;20  /** Delay between consecutive characters in seconds */21  stagger?: number;22  /** Where the stagger wave starts from */23  staggerFrom?: RollingTextStaggerFrom;24  /** Roll duration per character in seconds */25  duration?: number;26  /** GSAP-style ease name */27  ease?: RollingTextEase;28  /** Resting text color (defaults to the theme foreground) */29  color?: string;30  /** Color of the duplicate revealed on hover/focus */31  hoverColor?: string;32  /** Font size in pixels */33  fontSize?: number;34  /** Automatically roll back and forth as a demo loop (paused off-screen) */35  autoPlay?: boolean;36  /** Optional className merged onto the root */37  className?: string;38  /** Optional inline styles merged onto the root */39  style?: React.CSSProperties;40}41 42const EASES: Record<RollingTextEase, (t: number) => number> = {43  "power2.out": (t) => 1 - Math.pow(1 - t, 3),44  "power3.out": (t) => 1 - Math.pow(1 - t, 4),45  "power4.out": (t) => 1 - Math.pow(1 - t, 5),46  "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),47  "back.out": (t) => {48    const c = 1.70158;49    const u = t - 1;50    return 1 + (c + 1) * u * u * u + c * u * u;51  },52  "elastic.out": (t) =>53    t <= 0 ? 0 : t >= 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,54  "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,55  "circ.out": (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),56};57 58/** Samples an ease into a CSS linear() timing function (falls back to `ease` where unsupported). */59const toCssEasing = (name: RollingTextEase) => {60  const fn = EASES[name] ?? EASES["power3.out"];61  const pts: string[] = [];62  for (let i = 0; i <= 48; i++) pts.push(String(Math.round(fn(i / 48) * 10000) / 10000));63  return `linear(${pts.join(", ")})`;64};65 66const rand = (n: number) => {67  const x = Math.sin(n * 9301 + 49297) * 233280;68  return x - Math.floor(x);69};70 71function staggerRanks(count: number, from: RollingTextStaggerFrom): number[] {72  if (count <= 0) return [];73  const mid = (count - 1) / 2;74  if (from === "random") {75    const ranks = new Array<number>(count).fill(0);76    Array.from({ length: count }, (_, i) => i)77      .sort((a, b) => rand(a + 1) - rand(b + 1))78      .forEach((idx, r) => {79        ranks[idx] = r;80      });81    return ranks;82  }83  const raw = Array.from({ length: count }, (_, i) => {84    if (from === "end") return count - 1 - i;85    if (from === "center") return Math.abs(i - mid);86    if (from === "edges") return mid - Math.abs(i - mid);87    return i;88  });89  const min = Math.min(...raw);90  return raw.map((v) => v - min);91}92 93export function RollingText({94  text = "Explore the work",95  href,96  target,97  onClick,98  stagger = 0.025,99  staggerFrom = "start",100  duration = 0.55,101  ease = "power3.out",102  color,103  hoverColor = "#a78bfa",104  fontSize = 56,105  autoPlay = true,106  className = "",107  style,108}: RollingTextProps) {109  const rootRef = React.useRef<HTMLElement | null>(null);110  const [active, setActive] = React.useState(false);111  const chars = React.useMemo(() => Array.from(text), [text]);112  const ranks = React.useMemo(() => staggerRanks(chars.length, staggerFrom), [chars.length, staggerFrom]);113  const words = React.useMemo(() => text.split(" "), [text]);114  const wordStarts = React.useMemo(() => {115    let acc = 0;116    return words.map((w) => {117      const start = acc;118      acc += Array.from(w).length + 1;119      return start;120    });121  }, [words]);122 123  React.useEffect(() => {124    const root = rootRef.current;125    setActive(false);126    if (!autoPlay || !root) return;127    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;128    const period = Math.max(1.4, duration + stagger * chars.length + 0.9) * 1000;129    let timer: ReturnType<typeof setInterval> | null = null;130    const startLoop = () => {131      if (timer) return;132      timer = setInterval(() => setActive((a) => !a), period);133    };134    const stopLoop = () => {135      if (timer) clearInterval(timer);136      timer = null;137    };138    let io: IntersectionObserver | null = null;139    if (typeof IntersectionObserver !== "undefined") {140      io = new IntersectionObserver((entries) => {141        if (entries.some((e) => e.isIntersecting)) startLoop();142        else stopLoop();143      });144      io.observe(root);145    } else {146      startLoop();147    }148    const kick = setTimeout(() => setActive(true), 450);149    return () => {150      clearTimeout(kick);151      stopLoop();152      io?.disconnect();153    };154  }, [autoPlay, duration, stagger, chars.length]);155 156  const vars = {157    "--mf-rt-size": `${fontSize}px`,158    "--mf-rt-hover": hoverColor,159    "--mf-rt-duration": `${Math.max(0, duration)}s`,160    "--mf-rt-ease": toCssEasing(ease),161    ...(color ? { "--mf-rt-color": color } : {}),162    ...style,163  } as React.CSSProperties;164 165  const visual = (166    <span aria-hidden="true" className={styles.visual}>167      {words.map((word, wi) => (168        <React.Fragment key={`${word}-${wi}`}>169          <span className={styles.word}>170            {Array.from(word).map((ch, ci) => {171              const i = wordStarts[wi] + ci;172              return (173                <span174                  key={i}175                  className={styles.char}176                  style={{ "--mf-rt-delay": `${((ranks[i] ?? 0) * stagger).toFixed(3)}s` } as React.CSSProperties}177                >178                  <span className={styles.track}>179                    <span className={styles.face}>{ch}</span>180                    <span className={`${styles.face} ${styles.dup}`}>{ch}</span>181                  </span>182                </span>183              );184            })}185          </span>186          {wi < words.length - 1 ? " " : null}187        </React.Fragment>188      ))}189    </span>190  );191 192  const common = {193    className: `${styles.root} ${className}`.trim(),194    style: vars,195    "data-active": active ? "true" : undefined,196  };197  const content = (198    <>199      <span className={styles.srOnly}>{text}</span>200      {visual}201    </>202  );203 204  if (href) {205    return (206      <a207        {...common}208        ref={(el) => {209          rootRef.current = el;210        }}211        href={href}212        target={target}213        rel={target === "_blank" ? "noopener noreferrer" : undefined}214        onClick={onClick}215      >216        {content}217      </a>218    );219  }220  if (onClick) {221    return (222      <button223        {...common}224        ref={(el) => {225          rootRef.current = el;226        }}227        type="button"228        onClick={onClick}229      >230        {content}231      </button>232    );233  }234  return (235    <span236      {...common}237      ref={(el) => {238        rootRef.current = el;239      }}240    >241      {content}242    </span>243  );244}