A simple text scramble effect that reveals characters from left to right.
npx atelier-ui add text-scrambleimport { 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,
},
})
}
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])
}
// 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
}
}
}
| Name | Type | Default | Description |
|---|---|---|---|
children | string | — | Text content to scramble. Required. |
duration | number | 1 | Total duration of the reveal in seconds. |
playOnMount | boolean | true | Plays the scramble animation on mount. |
playOnHover | boolean | true | Replays the scramble animation on hover. |
characters | string | "abcdefghijklmnopq rstuvwxyz@!#*$%^&+_[]" | Characters used to scramble. |
render | ReactElement | function | <span/> | Override the wrapper element. |
Yugop Nakamura
Original text scramble effect
use-scramble
A react hook for text scrambling by tolis