A foundation block that mirrors a text element onto a WebGL plane while preserving accessibility.
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 WebGL plane sits exactly over the DOM element.
Add Atelier's WebGL Text to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add https://atelier-ui.com/r/webgl-text.json
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.This command will install all the dependencies this component uses.
npx shadcn@latest add https://atelier-ui.com/r/webgl-text.jsonInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install three @types/three @react-three/fiber"use client"
import { useThree } from "@react-three/fiber"
import {
type ComponentRef,
cloneElement,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { CanvasTexture, type Mesh, type Texture } from "three"
import { useDomPlane } from "../../hooks/use-dom-plane"
import { type Pointer, usePointerUv } from "../../hooks/use-pointer-uv"
import { type RenderProp, useRender } from "../../hooks/use-render"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type { Pointer }
type WebglTextProps = {
children: string
webglEnabled?: boolean
render?: RenderProp
material?: (map: Texture, pointer: Pointer) => React.ReactNode
zIndex?: number
segments?: number
pixelRatio?: number
/**
* Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
* Costs one layout read per frame, so only enable it when needed.
*/
autoReflow?: boolean
}
type PlaneProps = {
el: RefObject<ComponentRef<"span"> | null>
segments: number
material?: (map: Texture, pointer: Pointer) => React.ReactNode
pointer: Pointer
zIndex: number
autoReflow: boolean
pixelRatio: number
}
type PaintedLine = {
text: string
x: number
baseline: number
}
// Groups characters into visual lines from their rendered rects, so wrapped
// text paints exactly where the browser laid it out.
function measureLines(el: HTMLElement, origin: DOMRect, ascent: number, fontHeight: number) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const range = document.createRange()
const lines: PaintedLine[] = []
let current: PaintedLine | null = null
let lineTop = 0
let textNode = walker.nextNode()
while (textNode) {
const text = textNode.nodeValue ?? ""
for (let offset = 0; offset < text.length; offset++) {
const character = text[offset]
const whitespace = /\s/.test(character)
range.setStart(textNode, offset)
range.setEnd(textNode, offset + 1)
const rect = range.getBoundingClientRect()
// Collapsed whitespace (line breaks, repeated spaces) has no box.
if (whitespace && rect.width === 0) continue
if (!current || Math.abs(rect.top - lineTop) > fontHeight / 2) {
const top = rect.top + (rect.height - fontHeight) / 2
current = {
text: "",
x: rect.left - origin.left,
baseline: top + ascent - origin.top,
}
lines.push(current)
lineTop = rect.top
}
current.text += whitespace ? " " : character
}
textNode = walker.nextNode()
}
return lines
}
// 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, rect: DOMRect, pixelRatio: number) {
const ctx = canvas.getContext("2d")
if (!ctx) return
const dpr = Math.min(pixelRatio, window.devicePixelRatio || 1)
const { fontFamily, fontSize, fontWeight, fontStyle, letterSpacing, color } =
getComputedStyle(el)
canvas.width = Math.max(1, Math.ceil(rect.width * dpr))
canvas.height = Math.max(1, Math.ceil(rect.height * dpr))
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`
ctx.letterSpacing = letterSpacing
ctx.fillStyle = color
ctx.textBaseline = "alphabetic"
const probe = ctx.measureText("Hg")
const ascent = probe.fontBoundingBoxAscent
const fontHeight = probe.fontBoundingBoxAscent + probe.fontBoundingBoxDescent
for (const line of measureLines(el, rect, ascent, fontHeight)) {
ctx.fillText(line.text, line.x, line.baseline)
}
}
function textRect(el: HTMLElement) {
const range = document.createRange()
range.selectNodeContents(el)
const rect = range.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 ? rect : el.getBoundingClientRect()
}
function Plane({ el, segments, material, pointer, zIndex, autoReflow, pixelRatio }: PlaneProps) {
const mesh = useRef<Mesh>(null)
const size = useThree((s) => s.size)
const measureBounds = useDomPlane(el, mesh, { autoReflow, getRect: textRect })
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 = () => {
const rect = measureBounds()
if (!rect) return
const prevWidth = canvas.width
const prevHeight = canvas.height
paint(target, canvas, rect, pixelRatio)
// WebGL2 texture storage is immutable: a resized canvas can't be
// uploaded into the old allocation, so drop it and let three
// recreate the texture at the new size.
if (canvas.width !== prevWidth || canvas.height !== prevHeight) texture.dispose()
texture.needsUpdate = true
}
measure()
document.fonts.ready.then(measure)
// ResizeObserver never fires for inline elements (they have no box),
// so document.body is watched too to catch layout-affecting resizes.
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
const mo = new MutationObserver(measure)
mo.observe(target, {
characterData: true,
childList: true,
attributes: true,
subtree: true,
})
mo.observe(document.documentElement, { attributes: true })
mo.observe(document.body, { attributes: true })
const scheme = window.matchMedia("(prefers-color-scheme: dark)")
scheme.addEventListener("change", measure)
return () => {
ro.disconnect()
mo.disconnect()
scheme.removeEventListener("change", measure)
}
}, [el, canvas, texture, pixelRatio, size, measureBounds])
return (
<mesh ref={mesh} renderOrder={zIndex}>
<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,
zIndex = 0,
autoReflow = false,
pixelRatio = 2,
}: WebglTextProps) {
const el = useRef<ComponentRef<"span">>(null)
const pointer = usePointerUv(el, { enabled: webglEnabled, getRect: textRect })
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}
segments={segments}
material={material}
pointer={pointer}
zIndex={zIndex}
autoReflow={autoReflow}
pixelRatio={pixelRatio}
/>
</webglTeleport.In>
)}
</>
)
}
// 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
}
}
}
import { type RefObject, useEffect, useMemo } from "react"
import { Vector2 } from "three"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
type UsePointerUvOptions = {
enabled: boolean
/**
* Maps element UVs into cropped texture UVs when object-fit trims the
* media. Defaults to identity, so `texUv` mirrors `uv`.
*/
uvFit?: RefObject<{ x: number; y: number }>
getRect?: (el: HTMLElement) => DOMRect
}
/**
* Tracks the cursor over a DOM element as normalized UVs, mutated in place so
* shader materials can read it every frame without re-rendering React.
*/
export function usePointerUv(
el: RefObject<HTMLElement | null>,
{ enabled, uvFit, getRect }: UsePointerUvOptions,
): Pointer {
const pointer = useMemo<Pointer>(() => {
return {
uv: new Vector2(0.5, 0.5),
texUv: new Vector2(0.5, 0.5),
repeat: new Vector2(1, 1),
hover: 0,
}
}, [])
useEffect(() => {
if (!enabled) return
const target = el.current
if (!target) return
/*
* Pointer events still fire on the DOM element through opacity:0,
* so the browser tells us when the cursor is over it.
*/
const onMove = (event: PointerEvent) => {
const rect = getRect ? getRect(target) : target.getBoundingClientRect()
const x = (event.clientX - rect.left) / rect.width
const y = 1 - (event.clientY - rect.top) / rect.height
const fit = uvFit?.current ?? { x: 1, y: 1 }
pointer.uv.set(x, y)
pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)
}
const onEnter = () => (pointer.hover = 1)
const onLeave = () => (pointer.hover = 0)
target.addEventListener("pointermove", onMove)
target.addEventListener("pointerenter", onEnter)
target.addEventListener("pointerleave", onLeave)
/*
* Hover in too fast and pointerenter fires before these listeners
* attach, so seed hover from the live :hover state instead.
*/
if (target.matches(":hover")) pointer.hover = 1
return () => {
target.removeEventListener("pointermove", onMove)
target.removeEventListener("pointerenter", onEnter)
target.removeEventListener("pointerleave", onLeave)
}
}, [enabled, el, pointer, uvFit, getRect])
return pointer
}
import { useFrame, useThree } from "@react-three/fiber"
import { type RefObject, useCallback, useRef } from "react"
import type { Mesh } from "three"
type UseDomPlaneOptions = {
/**
* Re-measures the DOM rect every frame so the plane follows animated
* parents (motion, parallax). Costs one layout read per frame.
*/
autoReflow: boolean
fitScale?: RefObject<{ x: number; y: number }>
getRect?: (el: HTMLElement) => DOMRect
}
/**
* Positions and scales a mesh to cover a DOM element on the shared canvas.
* Scroll is applied every frame. Layout changes are not observed here: the
* caller calls `measureBounds` when the element resizes or repaints.
*/
export function useDomPlane(
el: RefObject<HTMLElement | null>,
mesh: RefObject<Mesh | null>,
{ autoReflow, fitScale, getRect }: UseDomPlaneOptions,
) {
const size = useThree((state) => state.size)
const viewport = useThree((state) => state.viewport)
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
const measureBounds = useCallback(() => {
const target = el.current
if (!target) return null
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds every render.
*/
const rect = getRect ? getRect(target) : 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
return rect
}, [el, getRect])
useFrame(() => {
const m = mesh.current
if (!m) return
const pxToWorld = viewport.height / size.height
const fit = fitScale?.current ?? { x: 1, y: 1 }
const transitioning = document.documentElement.hasAttribute("data-atelier-transitioning")
if ((autoReflow || transitioning) && el.current) {
const rect = getRect ? getRect(el.current) : el.current.getBoundingClientRect()
m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld
m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld
m.scale.x = rect.width * pxToWorld * fit.x
m.scale.y = rect.height * pxToWorld * fit.y
return
}
const { x, y, width, height } = bounds.current
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 * fit.x
m.scale.y = height * pxToWorld * fit.y
})
return measureBounds
}
"use client"
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 = (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
const getSnapshot = () => snapshot
function useItems() {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
return {
In({ children }: { children: ReactNode }) {
const id = useId()
useIsoLayoutEffect(() => {
items.set(id, children)
emit()
return () => {
items.delete(id)
emit()
}
}, [id, children])
return null
},
useItems,
Out() {
const list = useItems()
return (
<>
{list.map(([id, node]) => (
<Suspense key={id} fallback={null}>
{node}
</Suspense>
))}
</>
)
},
}
}
const webglTeleport = WebglTeleport()
const effectTeleport = WebglTeleport()
export function WebglPortal() {
return <webglTeleport.Out />
}
export { effectTeleport, webglTeleport }
"use client"
import { advance, Canvas, type CanvasProps, useStore, useThree } from "@react-three/fiber"
import { EffectComposer } from "@react-three/postprocessing"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ComponentRef, type ReactNode, useEffect, useRef, useState } from "react"
import type { Camera, Scene } from "three"
import { effectTeleport, WebglPortal } from "../webgl-portal/webgl-portal"
type WebglProviderProps = Omit<CanvasProps, "children" | "eventSource"> & {
children: ReactNode
className?: string
contained?: boolean
}
type WebglReadyOptions = {
scene?: Scene
camera?: Camera
enabled?: boolean
onReady?: () => void
}
export function useWebglReady({ scene, camera, enabled = true, onReady }: WebglReadyOptions = {}) {
const [ready, setReady] = useState(false)
const gl = useThree((state) => state.gl)
const defaultScene = useThree((state) => state.scene)
const defaultCamera = useThree((state) => state.camera)
const onReadyRef = useRef(onReady)
onReadyRef.current = onReady
const targetScene = scene ?? defaultScene
const targetCamera = camera ?? defaultCamera
useEffect(() => {
if (!enabled) return
let active = true
gl.compileAsync(targetScene, targetCamera).then(() => {
if (!active) return
requestAnimationFrame(() => {
if (!active) return
setReady(true)
onReadyRef.current?.()
})
})
return () => {
active = false
}
}, [gl, targetScene, targetCamera, enabled])
return ready
}
// Renders in Motion's `postRender` phase, after Lenis and Motion have
// updated. One shared driver serves every mounted provider.
type CanvasStore = ReturnType<typeof useStore>
const canvasStores = new Set<CanvasStore>()
let clockStart: number | null = null
function tick(data: FrameData) {
if (clockStart === null) clockStart = data.timestamp
// frameloop="never" expects the elapsed clock time in seconds.
const elapsed = (data.timestamp - clockStart) / 1000
let runGlobalEffects = true
for (const store of canvasStores) {
const state = store.getState()
if (state.internal.active) {
advance(elapsed, runGlobalEffects, state)
runGlobalEffects = false
}
}
}
function MotionFrameloop() {
const store = useStore()
useEffect(() => {
canvasStores.add(store)
if (canvasStores.size === 1) frame.postRender(tick, true)
return () => {
canvasStores.delete(store)
if (canvasStores.size === 0) cancelFrame(tick)
}
}, [store])
return null
}
function Effects() {
const effects = effectTeleport.useItems()
const gl = useThree((state) => state.gl)
const mounted = effects.length > 0
// EffectComposer sets `renderer.autoClear = false` and never restores it;
// without this the canvas keeps its last frame once the composer unmounts.
useEffect(() => {
if (!mounted) return
return () => {
gl.autoClear = true
}
}, [mounted, gl])
if (!mounted) return null
return (
<EffectComposer key={effects.length}>
<effectTeleport.Out />
</EffectComposer>
)
}
export function WebglProvider({
children,
className,
style,
contained = false,
...canvasProps
}: WebglProviderProps) {
const [eventSource, setEventSource] = useState<ComponentRef<"div"> | null>(null)
return (
<div
ref={setEventSource}
data-atelier-webgl=""
className={className}
style={contained ? { position: "relative" } : { display: "contents" }}
>
<Canvas
eventPrefix="client"
dpr={[1, 2]}
{...canvasProps}
frameloop="never"
eventSource={eventSource ?? undefined}
style={{
position: contained ? "absolute" : "fixed",
inset: 0,
pointerEvents: "none",
...style,
}}
>
<MotionFrameloop />
<WebglPortal />
<Effects />
</Canvas>
{children}
</div>
)
}
All WebGL components render through one shared canvas. Add the WebglProvider once at the root of your app:
import { WebglProvider } from "@/components/webgl-provider/webgl-provider";
export default function RootLayout({ children }) {
return <WebglProvider>{children}</WebglProvider>;
}With no material, the plane uses a meshBasicMaterial and looks identical to a plain <span>:
<WebglText>Hello world</WebglText>material lets you provide your own R3F material. It receives the rasterized Texture and a live pointer (UV + hover):
<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.
| Name | Type | Default | Description |
|---|---|---|---|
children | string | — | Text to render. Required. |
material | (map: Texture, pointer: Pointer) => ReactNode | — | Custom R3F material. Receives the rasterized texture and a live pointer. |
segments | number | 1 | Plane geometry subdivisions. Increase for vertex-displacing shaders. |
webglEnabled | boolean | true | Toggle the WebGL plane. When false, only the DOM span is rendered. |
render | ReactElement | function | <span /> | Override the wrapper element. |
zIndex | number | 0 | Render order within the WebGL portal. |
autoReflow | boolean | false | Re-measures the DOM rect every frame. |
pixelRatio | number | 2 | Canvas backing resolution, capped at device DPR. |
| Name | Type | Description |
|---|---|---|
uv | Vector2 | Normalized pointer position inside the element. (0, 0) is bottom-left, (1, 1) is top-right. |
hover | number | 1 while the pointer is over the element, 0 otherwise. |
React Three Fiber
React renderer for Three.js.
Motion
React animation library.
WebGL Provider (Atelier)
A single shared WebGL canvas for the whole app.