A 2D canvas-based animated pixelated SEO friendly text effect.
npx atelier-ui add pixelated-textimport { type ComponentRef, useEffect, useRef } from "react"
import { useFrameLoop } from "../../hooks/use-frame-loop"
import { type RenderProp, useRender } from "../../hooks/use-render"
export type PixelatedTextProps = {
pixelSize?: number
chaos?: number
depth?: number
colors?: string[]
fps?: number
children: React.ReactNode
render?: RenderProp
}
function randomIndex(length: number, exclude: number) {
if (length <= 1) return 0
if (exclude < 0) return Math.floor(Math.random() * length)
const index = Math.floor(Math.random() * (length - 1))
return index >= exclude ? index + 1 : index
}
function drawText(
ctx: CanvasRenderingContext2D,
textEl: HTMLElement,
color: string,
width: number,
height: number,
) {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const computed = getComputedStyle(textEl)
ctx.font = `${computed.fontStyle} ${computed.fontWeight} ${computed.fontSize} ${computed.fontFamily}`
ctx.letterSpacing = computed.letterSpacing
// match browser baseline placement relative to the font em square
const metrics = ctx.measureText(textEl.textContent || "")
const fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent
const leading = height - fontHeight
const y = leading / 2 + metrics.fontBoundingBoxAscent
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = color
ctx.textBaseline = "alphabetic"
ctx.fillText(textEl.textContent || "", 0, y)
}
export function PixelatedText({
pixelSize = 5,
chaos = 0.1,
depth = 6,
colors,
fps = 200,
children,
render,
}: PixelatedTextProps) {
const sizingRef = useRef<ComponentRef<"span">>(null)
const containerRef = useRef<ComponentRef<"span">>(null)
const canvasRef = useRef<ComponentRef<"canvas">>(null)
const bufferRef = useRef<{
source: HTMLCanvasElement
sourceCtx: CanvasRenderingContext2D
shrink: HTMLCanvasElement
shrinkCtx: CanvasRenderingContext2D
} | null>(null)
const stateRef = useRef({
width: 0,
height: 0,
pixelWidth: 0,
pixelHeight: 0,
colorIndex: -1,
hasRendered: false,
})
useFrameLoop(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext("2d")
const textEl = sizingRef.current
const buffer = bufferRef.current
if (!canvas || !ctx || !textEl || !buffer || !containerRef.current) return
const state = stateRef.current
let color: string
if (colors && colors.length > 0) {
const index = randomIndex(colors.length, state.colorIndex)
state.colorIndex = index
color = colors[index]
} else {
color = getComputedStyle(containerRef.current).color
}
drawText(buffer.sourceCtx, textEl, color, state.width, state.height)
const scale = state.pixelHeight / 100
const currentPixel = Math.max(
2,
Math.round((pixelSize + (Math.random() - 0.5) * depth) * scale),
)
const tinyWidth = Math.max(1, Math.ceil(state.pixelWidth / currentPixel))
const tinyHeight = Math.max(1, Math.ceil(state.pixelHeight / currentPixel))
buffer.shrink.width = tinyWidth
buffer.shrink.height = tinyHeight
const gridOffsetX = Math.round((Math.random() - 0.5) * currentPixel * chaos)
const gridOffsetY = Math.round((Math.random() - 0.5) * currentPixel * chaos)
buffer.shrinkCtx.imageSmoothingEnabled = false
buffer.shrinkCtx.drawImage(
buffer.source,
gridOffsetX,
gridOffsetY,
state.pixelWidth,
state.pixelHeight,
0,
0,
tinyWidth,
tinyHeight,
)
const dilate = Math.round(currentPixel * chaos * 0.4)
ctx.clearRect(0, 0, state.pixelWidth, state.pixelHeight)
ctx.imageSmoothingEnabled = false
ctx.drawImage(
buffer.shrink,
0,
0,
tinyWidth,
tinyHeight,
-dilate,
-dilate,
state.pixelWidth + dilate * 2,
state.pixelHeight + dilate * 2,
)
const randChaos = Math.random() * chaos * 3 - (chaos * 3) / 2
canvas.style.transform = `translate(${randChaos}px, ${randChaos}px)`
if (!state.hasRendered) {
state.hasRendered = true
canvas.style.opacity = "1"
if (sizingRef.current) sizingRef.current.style.visibility = "hidden"
}
}, fps)
useEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const source = document.createElement("canvas")
const shrink = document.createElement("canvas")
const state = stateRef.current
bufferRef.current = {
sourceCtx: source.getContext("2d")!,
shrinkCtx: shrink.getContext("2d")!,
source,
shrink,
}
const measure = () => {
const textEl = sizingRef.current
if (!textEl) return
const rect = textEl.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio || 1, 2)
if (rect.width === 0 || rect.height === 0) return
state.width = rect.width
state.height = rect.height
state.pixelWidth = Math.ceil(rect.width * dpr)
state.pixelHeight = Math.ceil(rect.height * dpr)
source.width = state.pixelWidth
source.height = state.pixelHeight
canvas.width = state.pixelWidth
canvas.height = state.pixelHeight
canvas.style.width = `${rect.width}px`
canvas.style.height = `${rect.height}px`
}
document.fonts.ready.then(measure)
const resizeObserver = new ResizeObserver(measure)
resizeObserver.observe(container)
return () => {
resizeObserver.disconnect()
bufferRef.current = null
stateRef.current.hasRendered = false
if (sizingRef.current) sizingRef.current.style.visibility = ""
}
}, [])
return useRender({
render,
defaultElement: <span />,
props: {
ref: containerRef,
className: "relative inline-block",
children: (
<>
<span ref={sizingRef} aria-hidden="true" className="inline-block">
{children}
</span>
<span className="sr-only">{children}</span>
<canvas
tabIndex={-1}
ref={canvasRef}
className="absolute inset-0 pointer-events-none touch-none"
style={{ opacity: 0 }}
aria-hidden="true"
/>
</>
),
},
})
}
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 | ReactNode | — | Text content to render. Required. |
pixelSize | number | 5 | Pixel size. |
chaos | number | 0.1 | Amount of chaos. |
depth | number | 6 | Amplitude (contrast) between clear and pixelated. |
colors | string[] | undefined | Cycles through colors each tick. Fallback to the default color. |
fps | number | 200 | Frames per second. |
render | ReactElement | function | <span/> | Override the wrapper element. |