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
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add rolling-textWrites 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.
1import { RollingText } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <RollingText />6 );7}Props
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
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}