SEO-friendly text with an animated WebGL shimmer and distortion effect.
npx atelier-ui add text-fluidnpm install three @react-three/fiber @react-three/dreiimport { 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>
)
}
// 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 { 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>
)}
</>
)
}
| Name | Type | Default | Description |
|---|---|---|---|
| children | string | — | Text content. |
| speed | number | 0.2 | Overall animation speed multiplier. |
| opacity | number | 1 | Overall opacity. |
| ripple | number | 0.015 | UV warp strength: how much the surface refracts. |
| amplitude | number | 0.002 | FBM displacement amplitude: how far vertices scatter. |
| frequency | number | 1.5 | FBM spatial frequency: size of the noise pattern. |
| segments | number | 20 | Plane geometry subdivisions, higher = better quality. |
| webglEnabled | boolean | true | Toggle WebGL effect on/off, falls back to plain text. |
| className | string | — | Class applied to the underlying span. |