Atelier UI®

Read the docsGithub
Docs 0.7.0

Getting started

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

Components (22)

  • Text Split
    new
  • WebGL Image
    new
  • WebGL Text
    new
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
    new
  • Image Trail
    new
  • Liquid Image
    new
  • Magnetic Dot Grid
    new
  • Pixel Trail
    new
  • Pixelated Text
    new
  • Text Bounce
    new
  • Text Fluid
    new
  • Text Roll
    new
  • Text Scramble
    new
  • Curve Image
    new
  • Elastic Stick
    pro
  • Infinite Gallery
    new
  • Infinite Parallax
    new
  • Infinite Zoom
    new
  • Scattered Scroll
    new
Atelier UI 0.7.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Webgl Text

WebGL Text

A primitive that mirrors an text element onto a WebGL plane while preserving accessibility.

Primitive
React Three Fiber

A structural building block for composing your own WebGL text effects, like Text Fluid.

The text is rendered twice: as a real <span> for SEO and screen readers (hidden when WebGL is on), and as a CanvasTexture painted onto a plane that tracks its bounding box. The two stay pixel-aligned, so the shader feels like it runs on the DOM.

Requires a <Canvas> with <WebglPortal /> at the root of your app, see Installation.


Install

npx atelier-ui add webgl-text
npm install three @react-three/fiber
webgl-text.tsx
import { useFrame, useThree } from "@react-three/fiber"
import {
    type ComponentRef,
    cloneElement,
    type RefObject,
    useEffect,
    useLayoutEffect,
    useMemo,
    useRef,
} from "react"
import { CanvasTexture, type Mesh, type Texture, Vector2 } from "three"
import { type RenderProp, useRender } from "../../hooks/use-render"
import { webglTeleport } from "../webgl-portal/webgl-portal"

export type Pointer = {
    uv: Vector2
    hover: number
}

type WebglTextProps = {
    children: string
    segments?: number
    webglEnabled?: boolean
    material?: (map: Texture, pointer: Pointer) => React.ReactNode
    render?: RenderProp
}

type PlaneProps = {
    el: RefObject<ComponentRef<"span"> | null>
    text: string
    segments: number
    material?: (map: Texture, pointer: Pointer) => React.ReactNode
    pointer: Pointer
}

// Paints the content of the text on a canvas, mirroring its computed CSS typography so it looks identical to the DOM element.
function paint(el: HTMLElement, canvas: HTMLCanvasElement, width: number, height: number) {
    const ctx = canvas.getContext("2d")
    if (!ctx) return

    const dpr = Math.min(window.devicePixelRatio || 1, 2)
    const computed = getComputedStyle(el)

    canvas.width = Math.max(1, Math.ceil(width * dpr))
    canvas.height = Math.max(1, Math.ceil(height * dpr))

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    ctx.clearRect(0, 0, width, height)
    ctx.font = `${computed.fontStyle} ${computed.fontWeight} ${computed.fontSize} ${computed.fontFamily}`
    ctx.letterSpacing = computed.letterSpacing
    ctx.fillStyle = computed.color
    ctx.textBaseline = "alphabetic"

    const text = el.textContent || ""
    const metrics = ctx.measureText(text)
    const fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent
    const y = (height - fontHeight) / 2 + metrics.fontBoundingBoxAscent

    ctx.fillText(text, 0, y)
}

function Plane({ el, segments, material, pointer }: PlaneProps) {
    const mesh = useRef<Mesh>(null)
    const size = useThree((s) => s.size)
    const viewport = useThree((s) => s.viewport)
    const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })

    const { canvas, texture } = useMemo(() => {
        const canvas = document.createElement("canvas")
        const texture = new CanvasTexture(canvas)
        return { canvas, texture }
    }, [])

    useEffect(() => {
        return () => {
            texture.dispose()
        }
    }, [texture])

    useLayoutEffect(() => {
        const target = el.current
        if (!target) return

        const measure = () => {
            // Rect in document coords (top/left offset by current scroll at measure time),
            // so we can later derive viewport position with just `window.scrollX/Y`,
            // instead of recalculating bounds on every render
            const rect = target.getBoundingClientRect()
            bounds.current.x = rect.left + window.scrollX
            bounds.current.y = rect.top + window.scrollY
            bounds.current.width = rect.width
            bounds.current.height = rect.height
            paint(target, canvas, rect.width, rect.height)
            texture.needsUpdate = true
        }

        measure()
        document.fonts.ready.then(measure)

        const ro = new ResizeObserver(measure)
        ro.observe(target)
        ro.observe(document.body)
        return () => ro.disconnect()
    }, [el, canvas, texture])

    useFrame(() => {
        const m = mesh.current
        if (!m) return
        const { x, y, width, height } = bounds.current
        const pxToWorld = viewport.height / size.height

        m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
        m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld
        m.scale.x = width * pxToWorld
        m.scale.y = height * pxToWorld
    })

    return (
        <mesh ref={mesh}>
            <planeGeometry args={[1, 1, segments, segments]} />
            {material ? (
                material(texture, pointer)
            ) : (
                <meshBasicMaterial map={texture} transparent />
            )}
        </mesh>
    )
}

export function WebglText({
    children,
    material,
    webglEnabled = true,
    segments = 1,
    render,
}: WebglTextProps) {
    const el = useRef<ComponentRef<"span">>(null)
    const pointer = useMemo<Pointer>(() => {
        return {
            uv: new Vector2(0.5, 0.5),
            hover: 0,
        }
    }, [])

    useEffect(() => {
        if (!webglEnabled) return
        const target = el.current
        if (!target) return

        // Track the pointer on the dom element directly and passed to the material
        const onMove = (e: PointerEvent) => {
            const { width, left, top, height } = target.getBoundingClientRect()
            const x = (e.clientX - left) / width
            const y = 1 - (e.clientY - top) / height
            pointer.uv.set(x, y)
        }

        const onEnter = () => (pointer.hover = 1)
        const onLeave = () => (pointer.hover = 0)

        target.addEventListener("pointermove", onMove)
        target.addEventListener("pointerenter", onEnter)
        target.addEventListener("pointerleave", onLeave)
        return () => {
            target.removeEventListener("pointermove", onMove)
            target.removeEventListener("pointerenter", onEnter)
            target.removeEventListener("pointerleave", onLeave)
        }
    }, [webglEnabled, pointer])

    const element = useRender({
        render,
        defaultElement: <span />,
        props: { ref: el, children },
    })
    // Force opacity:0 to win when WebGL is on, so a consumer can't accidentally
    // un-hide the DOM fallback through their render element's style.
    const host = webglEnabled
        ? cloneElement(element, { style: { ...element.props.style, opacity: 0 } })
        : element

    return (
        <>
            {host}

            {webglEnabled && (
                <webglTeleport.In>
                    <Plane
                        el={el}
                        text={children}
                        segments={segments}
                        material={material}
                        pointer={pointer}
                    />
                </webglTeleport.In>
            )}
        </>
    )
}
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
        }
    }
}
webgl-portal.tsx
import {
    type ReactNode,
    Suspense,
    useEffect,
    useId,
    useLayoutEffect,
    useSyncExternalStore,
} from "react"

const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect

// Minimal teleport: <In> registers children in an external store,
// <Out> renders them — bridges across the Canvas React root the same
function WebglTeleport() {
    const items = new Map<string, ReactNode>()
    const listeners = new Set<() => void>()
    let snapshot: [string, ReactNode][] = []

    const emit = () => {
        snapshot = Array.from(items.entries())
        for (const listener of listeners) {
            listener()
        }
    }

    const subscribe = (l: () => void) => {
        listeners.add(l)
        return () => {
            listeners.delete(l)
        }
    }
    const getSnapshot = () => snapshot

    return {
        In({ children }: { children: ReactNode }) {
            const id = useId()

            useIsoLayoutEffect(() => {
                items.set(id, children)
                emit()
                return () => {
                    items.delete(id)
                    emit()
                }
            }, [id, children])
            return null
        },
        Out() {
            const list = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
            return (
                <>
                    {list.map(([id, node]) => (
                        <Suspense key={id} fallback={null}>
                            {node}
                        </Suspense>
                    ))}
                </>
            )
        },
    }
}

const webglTeleport = WebglTeleport()

export function WebglPortal() {
    return <webglTeleport.Out />
}

export { webglTeleport }

Basic usage

With no material, the plane uses a meshBasicMaterial and looks identical to a plain <span>:

Default material
<WebglText>Hello world</WebglText>

With material

material lets you provide your own R3F material. It receives the rasterized Texture and a live pointer (UV + hover):

Custom shader material
<WebglText
    segments={20}
    material={(map, pointer) => (
        <myShaderMaterial
            uMap={map}
            uPointer={pointer.uv}
            uHover={pointer.hover}
            transparent
        />
    )}
>
    Hello world
</WebglText>

The pointer object is mutated in place, so reading it inside a useFrame gives current values without re-renders.

Use segments to subdivide the plane when your shader displaces vertices. 1 is enough for fragment-only effects.

Toggling WebGL off

Set webglEnabled={false} to skip the canvas pass and fall back to the plain DOM <span>:

Graceful fallback
<WebglText webglEnabled={!prefersReducedMotion}>Hello world</WebglText>

API

NameTypeDefaultDescription
childrenstring—Text to render. Required.
material(map: Texture, pointer: Pointer) => ReactNode—Custom R3F material. Receives the rasterized texture and a live pointer.
segmentsnumber1Plane geometry subdivisions. Increase for vertex-displacing shaders.
webglEnabledbooleantrueToggle the WebGL plane. When false, only the DOM span is rendered.
renderReactElement | function<span />Override the wrapper element.

Pointer

NameTypeDescription
uvVector2Normalized pointer position inside the element. (0, 0) is bottom-left, (1, 1) is top-right.
hovernumber1 while the pointer is over the element, 0 otherwise.
  • Install
  • Basic usage
  • With material
  • Toggling WebGL off
  • API
  • Pointer
Star on githubBuy me a coffeellms.txt