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
}
}
}
By default the text renders inside a <span>.
<PixelatedText>Pixelate me</PixelatedText>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.
<PixelatedText render={<h1 className="text-4xl font-bold" />}>Pixelate me</PixelatedText>| 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. |