A simple text scramble effect that reveals characters from left to right.
npx atelier-ui add text-scrambleimport { 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
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<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
})
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
}
}
}
## Integrate the <TextScramble /> component from Atelier UI
You are helping integrate an open-source React component into an existing application.
### Component: TextScramble
### Description: A simple text scramble effect that reveals characters from left to right.
### Dependencies:
---
### Usage Example
By default the text renders inside a `<span>`.
```tsx title="Default"
<TextScramble>Scramble me</TextScramble>
```
Pass `render` to swap the wrapper for any DOM element. Your props are merged with the component's, so it stays the right semantic tag (heading, paragraph, link) while keeping the animation.
```tsx title="Custom element"
<TextScramble render={<h1 className="text-4xl font-bold" />}>Scramble me</TextScramble>
```
---
### Props
| 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. |
---
### Full Component Source
#### src/components/text-scramble/text-scramble.tsx
```tsx
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
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<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
})
return useRender({
render,
defaultElement: <span />,
props: {
ref,
onTouchStart: playOnHover ? play : undefined,
onMouseEnter: playOnHover ? play : undefined,
children: text,
},
})
}
```
#### src/hooks/use-frame-loop.ts
```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])
}
```
#### src/hooks/use-render.ts
```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
}
}
}
```
---
### Integration Instructions
1. If you can execute shell commands, run `npx atelier-ui add text-scramble` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: .
3. Copy each file from the component source above to the exact path shown.
4. Render `<TextScramble />` where it belongs in the app, using the usage example as a starting point and the props table to adjust it.
Full documentation: https://atelier-ui.com/en/docs/components/text/text-scrambleBy default the text renders inside a <span>.
<TextScramble>Scramble me</TextScramble>Pass render to swap the wrapper for any DOM element. Your props are merged with the component's, so it stays the right semantic tag (heading, paragraph, link) while keeping the animation.
<TextScramble render={<h1 className="text-4xl font-bold" />}>Scramble me</TextScramble>| 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