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. Text Fluid

Text Fluid

SEO-friendly text with an animated WebGL shimmer and distortion effect.

React Three Fiber
Drei
https://atelier-ui.com/text-fluid

Settings

speed
0.2
opacity
1.00
ripple
0.015
amplitude
0.002
frequency
1.5
segments
20
webgl-enabled
See the documentation below for more options.

Install

npx atelier-ui add text-fluid
npm install three @react-three/fiber @react-three/drei
text-fluid.tsx
import { shaderMaterial } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import { useRef } from "react"
import { Texture } from "three"
import type { RenderProp } from "../../hooks/use-render"
import { WebglText } from "../webgl-text/webgl-text"

declare module "@react-three/fiber" {
    interface ThreeElements {
        textFluidMat: ThreeElement<typeof TextFluidMat>
    }
}

const vertexShader = /* glsl */ `
varying vec2 vUv;
uniform float uTime;
uniform float uNoiseFrequency;
uniform float uNoiseAmplitude;

float rand(vec2 n) {
    return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453);
}

float noise(vec2 p) {
    vec2 ip = floor(p);
    vec2 u = fract(p);
    u = u * u * (3.0 - 2.0 * u);
    float res = mix(
        mix(rand(ip), rand(ip + vec2(1.0, 0.0)), u.x),
        mix(rand(ip + vec2(0.0, 1.0)), rand(ip + vec2(1.0, 1.0)), u.x),
        u.y
    );
    return res * res;
}

float fbm(vec2 x) {
    float v = 0.0;
    float a = 0.5;
    vec2 shift = vec2(100.0);
    mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.5));
    for (int i = 0; i < 6; ++i) {
        v += a * noise(x);
        x = rot * x * 2.0 + shift;
        a *= 0.5;
    }
    return v;
}

void main() {
    vUv = uv;
    vec3 newPos = position;

    float n = fbm(newPos.xy * uNoiseFrequency + uTime);
    newPos.y += (n - 0.25) * uNoiseAmplitude * 50.0;
    newPos.x += (n - 0.25) * uNoiseAmplitude * 100.0;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}
`

const fragmentShader = /* glsl */ `
precision highp float;

uniform sampler2D uTexture;
uniform float uTime;
uniform float uRippleStrength;
uniform float uOpacity;

varying vec2 vUv;

void main() {
    vec2 uv = vUv;
    uv.x += sin(vUv.y * 8.5 + uTime * 1.8) * uRippleStrength;
    uv.y += cos(vUv.x * 8.5 + uTime * 1.4) * uRippleStrength;
    uv = clamp(uv, 0.0, 1.0);

    vec4 tex = texture2D(uTexture, uv);
    if (tex.a < 0.01) discard;

    gl_FragColor = vec4(tex.rgb, tex.a * uOpacity);
}
`

const TextFluidMat = shaderMaterial(
    {
        uTexture: new Texture(),
        uTime: 0,
        uRippleStrength: 0,
        uNoiseFrequency: 0,
        uNoiseAmplitude: 0,
        uOpacity: 0,
    },
    vertexShader,
    fragmentShader,
)

extend({ TextFluidMat })

export type DynamicNumber = number | (() => number)
const read = (v: DynamicNumber) => (typeof v === "function" ? v() : v)

type TextFluidMaterialProps = {
    uTexture: Texture
    speed: number
    ripple: DynamicNumber
    frequency: DynamicNumber
    amplitude: DynamicNumber
    opacity: DynamicNumber
}

export type TextFluidProps = {
    children: string
    speed?: number
    ripple?: DynamicNumber
    frequency?: DynamicNumber
    amplitude?: DynamicNumber
    opacity?: DynamicNumber
    segments?: number
    webglEnabled?: boolean
    render?: RenderProp
}

function TextFluidMaterial({
    uTexture,
    speed,
    ripple,
    frequency,
    amplitude,
    opacity,
}: TextFluidMaterialProps) {
    const ref = useRef<InstanceType<typeof TextFluidMat>>(null)

    useFrame(({ clock }) => {
        const mat = ref.current
        if (!mat) return
        mat.uTime = clock.getElapsedTime() * speed
        mat.uRippleStrength = read(ripple)
        mat.uNoiseFrequency = read(frequency)
        mat.uNoiseAmplitude = read(amplitude)
        mat.uOpacity = read(opacity)
    })

    return (
        <textFluidMat
            ref={ref}
            key={TextFluidMat.key}
            attach="material"
            uTexture={uTexture}
            transparent
            depthWrite={false}
        />
    )
}

export function TextFluid({
    children,
    speed = 0.2,
    ripple = 0.015,
    frequency = 1.5,
    amplitude = 0.002,
    opacity = 1,
    segments = 20,
    webglEnabled = true,
    render,
}: TextFluidProps) {
    return (
        <WebglText
            render={render}
            segments={segments}
            webglEnabled={webglEnabled}
            material={(uTexture) => (
                <TextFluidMaterial
                    uTexture={uTexture}
                    speed={speed}
                    ripple={ripple}
                    frequency={frequency}
                    amplitude={amplitude}
                    opacity={opacity}
                />
            )}
        >
            {children}
        </WebglText>
    )
}
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-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
    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
}

// 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,
    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(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 = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`
    ctx.letterSpacing = letterSpacing
    ctx.fillStyle = 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, zIndex, autoReflow, pixelRatio }: 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, pixelRatio)
        }

        measure()
        document.fonts.ready.then(measure)
        const ro = new ResizeObserver(measure)
        ro.observe(target)

        return () => {
            ro.disconnect()
        }
    }, [el, canvas, texture, pixelRatio])

    useFrame(() => {
        const m = mesh.current
        if (!m) return
        const pxToWorld = viewport.height / size.height

        // autoReflow re-reads the rect each frame so the mesh follows parent
        // CSS transforms (e.g. parallax). One layout read per frame.
        if (autoReflow && el.current) {
            const rect = 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
            m.scale.y = rect.height * pxToWorld
            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
        m.scale.y = height * pxToWorld
    })

    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 = useMemo<Pointer>(() => {
        return {
            uv: new Vector2(0.5, 0.5),
            hover: 0,
        }
    }, [])

    useEffect(() => {
        if (!webglEnabled) 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 = (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}
                        segments={segments}
                        material={material}
                        pointer={pointer}
                        zIndex={zIndex}
                        autoReflow={autoReflow}
                        pixelRatio={pixelRatio}
                    />
                </webglTeleport.In>
            )}
        </>
    )
}

API

NameTypeDefaultDescription
childrenstring—Text content.
speednumber0.2Overall animation speed multiplier.
opacitynumber1Overall opacity.
ripplenumber0.015UV warp strength: how much the surface refracts.
amplitudenumber0.002FBM displacement amplitude: how far vertices scatter.
frequencynumber1.5FBM spatial frequency: size of the noise pattern.
segmentsnumber20Plane geometry subdivisions, higher = better quality.
webglEnabledbooleantrueToggle WebGL effect on/off, falls back to plain text.
classNamestring—Class applied to the underlying span.
  • Install
  • API
Star on githubBuy me a coffeellms.txt