Atelier UI®

Read the docsGithub
Docs 0.7.0
  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct

Components (17)

  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
    new
  • Image Trail
    new
  • Liquid Touch
    new
  • Magnetic Dot Grid
    new
  • Pixel Trail
    new
  • Pixelated Text
    new
  • Simple Scramble
    new
  • Text Bounce
    new
  • Text Roll
    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 coffee
  1. Docs
  2. /
  3. Components
  4. /
  5. Text Roll

Text Roll

Interactive CSS rolling text effect on hover, mount and scroll.

Tailwind CSS
CSS
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.css
@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%);
    }
}
text-roll.tsx
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>
    )
}
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 (clones do not linger in the DOM).
  • CLI Install
  • Manual Install
  • API
Star on githubBuy me a coffee