Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • How it works
  • License
  • MCP

Page Transition (04)

  • Clip Transition
  • Stripe Transition
  • Pixel Transition
  • Band Transition

Components (36)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Glowing Fog
  • Gradient Flow
  • Halftone Glow
  • Scattered Grid
  • Tag Cloud
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Dither Cursor
  • Hover Burst
  • Image Bloom
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
  • Letter Swarm
  • Magnify Trail
  • Stacking Grid
  • Wavy Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Falling Text
  • Text Roll

Foundation Blocks (07)

  • Smooth 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. Webgl Text

WebGL Text

A foundation block that mirrors a text element onto a WebGL plane while preserving accessibility.

  • Install
  • With material
  • API
  • Pointer

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.


Install

Prompt
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.json

Install 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
webgl-text.tsx
"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>
            )}
        </>
    )
}
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
        }
    }
}
use-pointer-uv.ts
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
}
use-dom-plane.ts
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
}
webgl-portal.tsx
"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 }
webgl-provider.tsx
"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:

Root layout
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>:

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.


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.
zIndexnumber0Render order within the WebGL portal.
autoReflowbooleanfalseRe-measures the DOM rect every frame.
pixelRationumber2Canvas backing resolution, capped at device DPR.

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.

React Three Fiber
React renderer for Three.js.

Motion
React animation library.

WebGL Provider (Atelier)
A single shared WebGL canvas for the whole app.

Star on githubBuy me a coffeellms.txt