Interactive rolling text effect on hover, mount and scroll.
npx atelier-ui add text-rollnpm install motionimport { animate, type Easing } from "motion"
const EASE: Easing = [0.84, 0, 0.22, 1]
import { type ComponentRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"
import { type RenderProp, useRender } from "../../hooks/use-render"
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
render?: RenderProp
} & Partial<AnimationProps>
type DisplaceLetterProps = {
ref: React.Ref<DisplaceLetterHandle>
char: string
index: number
enableHover: boolean
onComplete?: () => void
} & AnimationProps
export type DisplaceLetterHandle = {
play: () => void
}
function getDir(dir: TextRollProps["direction"]): 1 | -1 {
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,
onComplete,
}: DisplaceLetterProps) {
const isPlayingRef = useRef(false)
const clonedCharsRef = useRef<(ComponentRef<"span"> | null)[]>([])
const currentCharRef = useRef<ComponentRef<"span"> | null>(null)
const startPlay = useCallback(async () => {
if (isPlayingRef.current) return
isPlayingRef.current = true
const dir = getDir(direction)
clonedCharsRef.current.forEach((clone, i) => {
if (!clone) return
if (axis === "y") {
clone.style.top = `${(i + 1) * dir * -100}%`
clone.style.left = "0"
} else {
clone.style.left = `${(i + 1) * dir * -100}%`
clone.style.top = "0"
}
})
await animate(
[currentCharRef.current, ...clonedCharsRef.current.filter((span) => span !== null)],
axis === "y"
? { y: [`${dir * cycles * 100}%`, "0%"] }
: { x: [`${dir * cycles * 100}%`, "0%"] },
{
duration,
delay: stagger * index,
ease: EASE,
},
)
isPlayingRef.current = false
onComplete?.()
}, [axis, direction, stagger, duration, cycles, index, onComplete])
useImperativeHandle(ref, () => ({ play: startPlay }), [startPlay])
return (
<span
suppressHydrationWarning
onMouseEnter={enableHover ? startPlay : undefined}
className="overflow-clip inline-block relative whitespace-pre"
>
{Array.from({ length: cycles }, (_, i) => (
<span
key={i}
ref={(clone) => {
clonedCharsRef.current[i] = clone
}}
aria-hidden={true}
className="absolute"
>
{char}
</span>
))}
<span ref={currentCharRef} className="block">
{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,
render,
}: TextRollProps) {
const containerRef = useRef<ComponentRef<"span">>(null)
const letterRefs = useRef<(DisplaceLetterHandle | null)[]>([])
const isPlayingRef = useRef(false)
const completedRef = useRef(0)
const handleLetterComplete = useCallback(() => {
completedRef.current++
if (completedRef.current >= letterRefs.current.length) {
isPlayingRef.current = false
completedRef.current = 0
}
}, [])
const triggerPlay = useCallback(() => {
if (isPlayingRef.current) return
isPlayingRef.current = true
completedRef.current = 0
for (const letter of letterRefs.current) {
letter?.play()
}
}, [])
useEffect(() => {
if (playOnMount) triggerPlay()
}, [playOnMount, triggerPlay])
useEffect(() => {
if (!playOnScroll) return
const container = containerRef.current
if (!container) return
let isFirstRender = true
const obs = new IntersectionObserver(([entry]) => {
if (isFirstRender) {
isFirstRender = false
return
}
if (entry.isIntersecting) triggerPlay()
})
obs.observe(container)
return () => obs.disconnect()
}, [playOnScroll, triggerPlay])
return useRender({
render,
defaultElement: <span />,
props: {
ref: containerRef,
onMouseEnter: playOnHover && mode === "group" ? triggerPlay : undefined,
children: (
<TextSplit
showMask={false}
splitBy="letters"
renderItems={(char, index) => (
<DisplaceLetter
ref={(letter) => {
letterRefs.current[index] = letter
}}
char={char}
index={index}
enableHover={playOnHover && mode === "letters"}
direction={direction}
axis={axis}
stagger={stagger}
duration={duration}
cycles={cycles}
onComplete={handleLetterComplete}
/>
)}
>
{children}
</TextSplit>
),
},
})
}
// biome-ignore-all lint/suspicious/noExplicitAny: prop merging is inherently dynamic
/**
* Inspired by Base UI's `useRender` + `mergeProps`, intentionally simplified for this
* library's scope at the moment.
*
* Chosen over polymorphic prop: cleaner TypeScript, integrates better with other
* component (Next/Image, design systems, third-party UI libraries)
*
* @see https://base-ui.com/react/utils/use-render
* @see https://base-ui.com/react/utils/merge-props
*/
import { cloneElement, isValidElement, type ReactElement, type Ref } from "react"
type AnyProps = Record<string, any>
type RenderFunction<S> = (props: AnyProps, state: S) => ReactElement
export type RenderProp<S = void> = ReactElement | RenderFunction<S>
type UseRenderOptions<S> = {
render: RenderProp<S> | undefined
props: AnyProps
state?: S
defaultElement: ReactElement
}
export function useRender<S = void>(options: UseRenderOptions<S>): ReactElement<AnyProps> {
const { render, props, state, defaultElement } = options
const target = render ?? defaultElement
// Function form: consumer wires props themselves, no merging needed.
if (typeof target === "function") {
return target(props, state as S) as ReactElement<AnyProps>
}
// Element form: clone and merge our internal props with whatever the consumer set on the element.
const targetProps = (isValidElement(target) ? target.props : {}) as AnyProps
return cloneElement(target, mergeProps(props, targetProps)) as ReactElement<AnyProps>
}
function mergeProps(internal: AnyProps, external: AnyProps): AnyProps {
const merged: AnyProps = { ...internal }
for (const key in external) {
const internalValue = internal[key]
const externalValue = external[key]
if (key === "className" && typeof externalValue === "string") {
merged[key] = [internalValue, externalValue].filter(Boolean).join(" ")
} else if (key === "style" && externalValue && typeof externalValue === "object") {
merged[key] = { ...internalValue, ...externalValue }
} else if (key === "ref") {
merged[key] = composeRefs(internalValue, externalValue)
} else if (
key.startsWith("on") &&
typeof internalValue === "function" &&
typeof externalValue === "function"
) {
// External handler runs first so consumers can stopPropagation before our logic fires.
merged[key] = chainFunctions(externalValue, internalValue)
} else {
merged[key] = externalValue
}
}
return merged
}
function chainFunctions(...fns: Array<(...args: any[]) => void>) {
return (...args: any[]) => {
for (const fn of fns) fn(...args)
}
}
function composeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node)
else if (ref != null) (ref as { current: T | null }).current = node
}
}
}
import { Fragment, type ReactNode } from "react"
import { type RenderProp, useRender } from "../../hooks/use-render"
type SplitBy = "letters" | "words"
export type TextSplitProps = {
children: string
splitBy?: SplitBy
showMask?: boolean
renderItems?: (char: string, index: number) => ReactNode
render?: RenderProp
}
function splitText(text: string, splitBy: SplitBy) {
if (splitBy === "letters") return text.split("")
if (splitBy === "words") return text.split(" ")
return []
}
function Mask({ children, showMask }: { children: ReactNode; showMask: boolean }) {
if (!showMask) return children
return <span className="overflow-clip">{children}</span>
}
export function TextSplit({
children,
splitBy = "letters",
showMask = true,
renderItems,
render,
}: TextSplitProps) {
const parts = splitText(children, splitBy)
const content = parts.map((part, index) => {
const spacer = index < parts.length - 1 && " "
const elements = splitText(part, splitBy)
return (
<Mask showMask={showMask} key={index}>
{elements.map((char, i) => (
<Fragment key={i}>{renderItems ? renderItems(char, index) : char}</Fragment>
))}
{splitBy !== "letters" && spacer}
</Mask>
)
})
return useRender({
render,
defaultElement: <span />,
props: { children: content },
})
}
By default the text renders inside a <span>.
<TextRoll>Roll me</TextRoll>Pass render to swap the wrapper for any DOM element. Your props are merged with the component's, so it stays the right semantic tag (heading, paragraph, link) while keeping the animation.
<TextRoll render={<h1 className="text-4xl font-bold" />}>Roll me</TextRoll>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. |
render | ReactElement | function | <span/> | Override the wrapper element. |