A primitive that mirrors an 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 shader feels like it runs on the DOM.
Requires a <Canvas> with <WebglPortal /> at the root of your app, see Installation.
npx atelier-ui add webgl-textnpm install three @react-three/fiberimport { 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>
)}
</>
)
}
// 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 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 }
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.
Set webglEnabled={false} to skip the canvas pass and fall back to the plain DOM <span>:
<WebglText webglEnabled={!prefersReducedMotion}>Hello world</WebglText>| 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. |
| 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. |