Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (34)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Orbit Gallery
  • Sphere Gallery
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Pixelated Text

Pixelated Text

A 2D canvas-based animated pixelated SEO friendly text effect.

Canvas
Tailwind CSS
https://atelier-ui.com/pixelated-text

Settings

pixel-size
5.0
chaos
0.1
depth
6.0
fps
200
See the documentation below for more options.

Install

npx atelier-ui add pixelated-text
pixelated-text.tsx
import { 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"
                    />
                </>
            ),
        },
    })
}
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
        }
    }
}

Usage

By default the text renders inside a <span>.

Default
<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.

Custom element
<PixelatedText render={<h1 className="text-4xl font-bold" />}>Pixelate me</PixelatedText>

API

NameTypeDefaultDescription
childrenReactNode—Text content to render. Required.
pixelSizenumber5Pixel size.
chaosnumber0.1Amount of chaos.
depthnumber6Amplitude (contrast) between clear and pixelated.
colorsstring[]undefinedCycles through colors each tick. Fallback to the default color.
fpsnumber200Frames per second.
renderReactElement | function<span/>Override the wrapper element.
  • Install
  • Usage
  • API
Star on githubBuy me a coffeellms.txt