Interactive CSS rolling text effect on hover, mount and scroll.
npx atelier-ui add text-rollnpm install @keyframes aui-keyframes-slide {
from {
transform: translate3d(var(--aui-roll-tx), var(--aui-roll-ty), 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
[data-aui-roll-axis="y"] [data-aui-roll-clone] {
top: calc(var(--aui-roll-clone-step) * var(--aui-roll-dir, 1) * -100%);
left: 0;
}
[data-aui-roll-axis="x"] [data-aui-roll-clone] {
left: calc(var(--aui-roll-clone-step) * var(--aui-roll-dir, 1) * -100%);
top: 0;
}
[data-aui-roll-letter][data-aui-roll-playing] {
[data-aui-roll-real],
[data-aui-roll-clone] {
will-change: transform;
animation: aui-keyframes-slide var(--aui-roll-dur, 0.9s) cubic-bezier(0.84, 0, 0.22, 1)
var(--aui-roll-delay, 0s) both;
}
&[data-aui-roll-axis="y"] {
--aui-roll-tx: 0;
--aui-roll-ty: calc(var(--aui-roll-dir) * var(--aui-roll-cycles) * 100%);
}
&[data-aui-roll-axis="x"] {
--aui-roll-ty: 0;
--aui-roll-tx: calc(var(--aui-roll-dir) * var(--aui-roll-cycles) * 100%);
}
}
import "./text-roll.css"
import { type ComponentRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"
import { TextSplit } from "../text-split/text-split"
type AnimationProps = {
axis: "x" | "y"
direction: "forward" | "backward" | "random"
stagger: number
duration: number
cycles: number
}
export type TextRollProps = {
children: string
mode?: "letters" | "group"
playOnScroll?: boolean
playOnMount?: boolean
playOnHover?: boolean
} & Partial<AnimationProps>
type DisplaceLetterProps = {
ref: React.Ref<DisplaceLetterHandle>
char: string
index: number
enableHover: boolean
} & AnimationProps
export type DisplaceLetterHandle = {
play: () => void
}
function getDir(dir: TextRollProps["direction"]) {
if (dir === "random") return Math.random() > 0.5 ? "-1" : "1"
if (dir === "forward") return "1"
return "-1"
}
function DisplaceLetter({
ref,
char,
index,
enableHover,
axis,
direction,
stagger,
duration,
cycles,
}: DisplaceLetterProps) {
const wrapperRef = useRef<ComponentRef<"span">>(null)
const isPlayingRef = useRef(false)
const startPlay = useCallback(() => {
const el = wrapperRef.current
if (!el) return
if (isPlayingRef.current) return
isPlayingRef.current = true
el.style.setProperty("--aui-roll-dir", getDir(direction))
el.dataset.auiRollPlaying = "true"
}, [direction])
useImperativeHandle(ref, () => ({ play: startPlay }), [startPlay])
const handleAnimationEnd = (e: React.AnimationEvent) => {
if (e.animationName !== "aui-keyframes-slide") return
wrapperRef.current?.removeAttribute("data-aui-roll-playing")
isPlayingRef.current = false
}
return (
<span
ref={wrapperRef}
suppressHydrationWarning
onMouseEnter={enableHover ? startPlay : undefined}
onAnimationEnd={handleAnimationEnd}
className="overflow-clip inline-block relative whitespace-pre"
data-aui-roll-letter={true}
data-aui-roll-axis={axis}
style={
{
"--aui-roll-dur": `${duration}s`,
"--aui-roll-delay": `${stagger * index}s`,
"--aui-roll-cycles": cycles,
} as React.CSSProperties
}
>
{Array.from({ length: cycles }, (_, i) => (
<span
key={i}
aria-hidden={true}
className="absolute"
data-aui-roll-clone={true}
style={{ "--aui-roll-clone-step": i + 1 } as React.CSSProperties}
>
{char}
</span>
))}
<span className="block" data-aui-roll-real={true}>
{char}
</span>
</span>
)
}
export function TextRoll({
children,
axis = "y",
mode = "letters",
direction = "random",
playOnScroll = true,
playOnMount = true,
playOnHover = true,
stagger = 0,
duration = 0.8,
cycles = 2,
}: TextRollProps) {
const containerRef = useRef<ComponentRef<"span">>(null)
const letterRefs = useRef<(DisplaceLetterHandle | null)[]>([])
const isPlayingRef = useRef(false)
const accumulatedEndsRef = useRef(0)
const triggerPlay = useCallback(() => {
if (isPlayingRef.current) return
isPlayingRef.current = true
for (const letter of letterRefs.current) {
letter?.play()
}
}, [])
const handleGroupAnimationEnd = (e: React.AnimationEvent) => {
if (e.animationName !== "aui-keyframes-slide") return
accumulatedEndsRef.current++
const expected = letterRefs.current.length * (cycles + 1)
if (accumulatedEndsRef.current >= expected) {
isPlayingRef.current = false
accumulatedEndsRef.current = 0
}
}
useEffect(() => {
if (playOnMount) triggerPlay()
}, [playOnMount, triggerPlay])
useEffect(() => {
if (!playOnScroll) return
const el = containerRef.current
if (!el) return
let isFirstRender = true
const obs = new IntersectionObserver(([entry]) => {
if (isFirstRender) {
isFirstRender = false
return
}
if (entry.isIntersecting) triggerPlay()
})
obs.observe(el)
return () => obs.disconnect()
}, [playOnScroll, triggerPlay])
return (
<span
ref={containerRef}
onMouseEnter={playOnHover && mode === "group" ? triggerPlay : undefined}
onAnimationEnd={handleGroupAnimationEnd}
>
<TextSplit
showMask={false}
splitBy="letters"
renderItems={(char, index) => (
<DisplaceLetter
ref={(el) => {
letterRefs.current[index] = el
}}
char={char}
index={index}
enableHover={playOnHover && mode === "letters"}
direction={direction}
axis={axis}
stagger={stagger}
duration={duration}
cycles={cycles}
/>
)}
>
{children}
</TextSplit>
</span>
)
}
import React from "react"
type SplitBy = "letters" | "words"
type SplitTextProps = {
children: string
splitBy?: SplitBy
showMask?: boolean
side?: "x" | "y"
renderItems?: (char: string, index: number) => React.ReactNode
}
type MaskProps = {
children: React.ReactNode
showMask: boolean
} & React.HTMLAttributes<HTMLSpanElement>
function splitText(text: string, splitBy: SplitBy) {
if (splitBy === "letters") return text.split("")
if (splitBy === "words") return text.split(" ")
return []
}
const Mask = ({ children, showMask, ...props }: MaskProps) => {
if (!showMask) return children
return (
<span className="overflow-clip" {...props}>
{children}
</span>
)
}
export function TextSplit({
children,
splitBy = "letters",
showMask = true,
renderItems,
...props
}: SplitTextProps & React.HTMLAttributes<HTMLSpanElement>) {
if (typeof children !== "string") throw new Error("SplitText only accepts string children")
const element = splitText(children, splitBy)
return (
<>
{element.map((part, index) => {
const spacer = index < element.length - 1 && " "
const elements = splitText(part, splitBy)
return (
<Mask {...props} showMask={showMask} key={index}>
{elements.map((char, i) => {
return (
<React.Fragment key={i}>
{renderItems ? renderItems(char, index) : char}
</React.Fragment>
)
})}
{splitBy !== "letters" && spacer}
</Mask>
)
})}
</>
)
}
Each letter is wrapped in an overflow-clip span. Some descenders and ascenders (g, p, t, f) can get clipped. Increase line-height / leading on the parent if needed.
| Name | Type | Default | Description |
|---|---|---|---|
| children | string | — | The text content to animate. |
| axis | "x" | "y" | "y" | Axis along which letters roll. |
| mode | "letters" | "group" | "letters" | Animate each letter independently on hover, or the entire group at once. |
| direction | "forward" | "backward" | "random" | "forward" | Roll direction. |
| playOnScroll | boolean | true | Trigger the animation when the element enters the viewport. |
| playOnMount | boolean | true | Trigger the animation on mount. |
| playOnHover | boolean | true | Trigger the animation on hover. |
| stagger | number | 0.01 | Delay in seconds between each letter's animation start. |
| duration | number | 0.8 | Duration of each letter's roll animation in seconds. |
| cycles | number | 1 | Number of times the letter rolls before settling (clones do not linger in the DOM). |