Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

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

Components (28)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • 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
  • Scattered Scroll
  • Text Split
  • WebGL Image
  • 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
        }
    }
}

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
  • API
Star on githubBuy me a coffeellms.txt