Sable UI®

DocsCatalogGithub
Docs 1.0.0

tools

  • Browse Catalog

Documentation

  • How it works
  • How to contribute

Components (21)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble

Foundation Blocks (07)

  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Sable UI 1.0.0 ©2026
Star on githubllms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Text Scramble

Text Scramble

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

https://sable-ui.com/text-scramble
duration
0.7
scramble-fps
30
play-on-mount
play-on-hover
duration
0.7
scramble-fps
30
play-on-mount
play-on-hover
Prompt
Add Sable's Text Scramble to my app.
Run: npx shadcn@latest add @sable/text-scramble
That writes the sable-ui skill under .agents/skills and .claude/skills. Follow it.

Props: duration={0.7} scrambleFps={30} playOnMount={true} playOnHover={true}

Just into your favorite coding agent and let Sable do its magic. Live props are included.


This command will install all the dependencies this component uses.

npx shadcn@latest add /text-scramble

Install the dependencies first, then feel free to copy the files into your project as you see fit.

text-scramble.tsx
"use client"

import { type ComponentRef, 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
    scrambleFps?: number
    playOnMount?: boolean
    playOnHover?: boolean
    characters?: string
    render?: RenderProp
}

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

    const ref = useRef<ComponentRef<"span">>(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
    }, 1000 / scrambleFps)

    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
        }
    }
}

The text scrambles through random characters and reveals from left to right. The animation plays on mount and replays on hover.

<TextScramble>Enter your text here</TextScramble>

Choose the wrapper element

Pass render to choose the wrapper element. Your props are merged with the component's, so it stays the right semantic tag while keeping the animation. Without render the text renders inside a <span>.

<TextScramble render={<p className="text-4xl" />}>Enter your text here</TextScramble>

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

No dependencies. This one runs on React and Tailwind CSS alone.

Star on githubllms.txt