Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

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

Components (22)

  • Text Split
    new
  • WebGL Image
    new
  • WebGL Text
    new
  • 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
  • Text Bounce
    new
  • Text Fluid
    new
  • Text Roll
    new
  • Text Scramble
    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 Scramble

Text Scramble

A simple text scramble effect that reveals characters from left to right.

JS Animation
Tailwind CSS
https://atelier-ui.com/text-scramble

Settings

duration
1.0
See the documentation below for more options.

Install

npx atelier-ui add text-scramble
text-scramble.tsx
import { useCallback, useEffect, useRef } from "react"
import { useFrameLoop } from "../../hooks/use-frame-loop"
import { type RenderProp, useRender } from "../../hooks/use-render"

export type TextScrambleProps = {
    children: string
    duration?: number
    playOnMount?: boolean
    playOnHover?: boolean
    characters?: string
    render?: RenderProp
}

export function TextScramble({
    children,
    duration = 1,
    playOnMount = true,
    playOnHover = true,
    characters = "abcdefghijklmnopqrstuvwxyz@!#*$%^&+_[]",
    render,
}: TextScrambleProps) {
    const text = children

    const ref = useRef<HTMLElement>(null)
    const startTime = useRef(0)
    const isAnimating = useRef(false)

    const play = useCallback(() => {
        startTime.current = 0
        isAnimating.current = true
    }, [])

    useEffect(() => {
        if (playOnMount) play()
    }, [text, playOnMount, play])

    useFrameLoop((time) => {
        if (!isAnimating.current) return
        if (!ref.current) return
        if (!startTime.current) startTime.current = time

        const elapsed = time - startTime.current
        const resolved = Math.floor((elapsed / duration) * text.length)

        if (resolved >= text.length) {
            ref.current.textContent = text
            isAnimating.current = false
            return
        }

        let next = ""
        for (let i = 0; i < text.length; i++) {
            if (i < resolved) next += text[i]
            else if (text[i] === " ") next += " "
            else next += characters[Math.floor(Math.random() * characters.length)]
        }

        ref.current.textContent = next
    })

    return useRender({
        render,
        defaultElement: <span />,
        props: {
            ref,
            onTouchStart: playOnHover ? play : undefined,
            onMouseEnter: playOnHover ? play : undefined,
            children: text,
        },
    })
}
use-frame-loop.ts
import { useEffect, useRef } from "react"

const DELTA_MAX = 0.1

type FrameLoopCallback = (time: number, delta: number) => void

export function useFrameLoop(callback: FrameLoopCallback, interval?: number) {
    const ref = useRef(callback)
    ref.current = callback

    useEffect(() => {
        let frameId = 0
        let lastTime = 0
        let lastTick = 0

        const tick = (now: number) => {
            frameId = requestAnimationFrame(tick)

            if (interval && now - lastTick < interval) return
            if (interval) lastTick = now

            const time = now * 0.001
            const delta = lastTime ? Math.min(time - lastTime, DELTA_MAX) : 0
            lastTime = time

            ref.current(time, delta)
        }

        frameId = requestAnimationFrame(tick)

        return () => {
            cancelAnimationFrame(frameId)
        }
    }, [interval])
}
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
        }
    }
}

API

NameTypeDefaultDescription
childrenstring—Text content to scramble. Required.
durationnumber1Total duration of the reveal in seconds.
playOnMountbooleantruePlays the scramble animation on mount.
playOnHoverbooleantrueReplays the scramble animation on hover.
charactersstring"abcdefghijklmnopq rstuvwxyz@!#*$%^&+_[]"Characters used to scramble.
renderReactElement | function<span/>Override the wrapper element.

Credits

Yugop Nakamura
Original text scramble effect

use-scramble
A react hook for text scrambling by tolis

  • Install
  • API
  • Credits
Star on githubBuy me a coffeellms.txt