Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (28)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Scattered Scroll
  • Text Split
  • WebGL Image
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Text Roll

Text Roll

Interactive rolling text effect on hover, mount and scroll.

Tailwind CSS
Motion
https://atelier-ui.com/text-roll

Settings

stagger
0.010
duration
0.8
cycles
2
play-on-hover
play-on-scroll
play-on-mount
See the documentation below for more options.

Install

npx atelier-ui add text-roll
text-roll.tsx
import { 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>
            ),
        },
    })
}
use-render.ts
// 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
        }
    }
}
text-split.tsx
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 },
    })
}

Notes

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.


API

NameTypeDefaultDescription
childrenstring—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.
playOnScrollbooleantrueTrigger the animation when the element enters the viewport.
playOnMountbooleantrueTrigger the animation on mount.
playOnHoverbooleantrueTrigger the animation on hover.
staggernumber0.01Delay in seconds between each letter's animation start.
durationnumber0.8Duration of each letter's roll animation in seconds.
cyclesnumber1Number of times the letter rolls before settling.
easeEasing[0.84, 0, 0.22, 1]Motion easing: bezier array, named string, or easing function.
renderReactElement | function<span/>Override the wrapper element.
  • Install
  • API
Star on githubBuy me a coffeellms.txt