Interactive rolling text effect on hover, mount and scroll.
npx atelier-ui add text-rollimport { animate, type Easing } from "motion"
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
ease: Easing
}
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,
ease,
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((el) => el !== null)],
axis === "y"
? { y: [`${dir * cycles * 100}%`, "0%"] }
: { x: [`${dir * cycles * 100}%`, "0%"] },
{
duration,
delay: stagger * index,
ease,
},
)
isPlayingRef.current = false
onComplete?.()
}, [axis, direction, stagger, duration, cycles, ease, 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={(el) => {
clonedCharsRef.current[i] = el
}}
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,
ease = [0.84, 0, 0.22, 1],
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 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 useRender({
render,
defaultElement: <span />,
props: {
ref: containerRef,
onMouseEnter: playOnHover && mode === "group" ? triggerPlay : undefined,
children: (
<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}
ease={ease}
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 },
})
}
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. |
ease | Easing | [0.84, 0, 0.22, 1] | Motion easing: bezier array, named string, or easing function. |
render | ReactElement | function | <span/> | Override the wrapper element. |