Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

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

Components (19)

  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
    new
  • Image Trail
    new
  • Liquid Image
    new
  • Magnetic Dot Grid
    new
  • Pixel Trail
    new
  • Pixelated Text
    new
  • Simple Scramble
    new
  • Text Bounce
    new
  • Text Fluid
    pro
  • Text Roll
    new
  • Curve Image
    new
  • Elastic Stick
    pro
  • Infinite Gallery
    new
  • Infinite Parallax
    new
  • Infinite Zoom
    new
  • Scattered Scroll
    new
Atelier UI 0.7.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.

CLI Install

npx atelier-ui add text-roll

Manual Install

npm install 
text-roll.tsx
import { animate, type Easing } from "motion"
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
    ease: Easing
}

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
    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],
}: 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 (
        <span
            ref={containerRef}
            onMouseEnter={playOnHover && mode === "group" ? triggerPlay : undefined}
        >
            <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>
        </span>
    )
}
text-split.tsx
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>
                )
            })}
        </>
    )
}

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.
  • CLI Install
  • Manual Install
  • API
Star on githubBuy me a coffeellms.txt