IrisComponentsLiquid Glass Grid
Liquid Glass Grid
An infinite grid of glass cards on a sphere, real WebGPU and TSL, no bevel geometry anywhere.
Liquid Glass Grid
Built from a Shader-studio write-up on Codrops: a flat plane per card, and a TSL fragment shader that fakes the rest — a 2D signed-distance field to a rounded rect turned into a height map, the height map's own slope standing in for a normal no geometry actually has, then chromatic-dispersion refraction, a fresnel-driven environment reflection, and a rim light on top. Drag it (or use the arrow keys) and the grid wraps with no seam, because it isn't a plane — each card's grid position is walked out onto a patch of a sphere, which is what reads as depth rather than a flat tile wrapping at the edge.
Runs on `three/webgpu` and `three/tsl` through React Three Fiber, the only piece in this catalogue that does — every background field elsewhere is a raw WebGL2 fragment shader. `WebGPURenderer` carries its own WebGPU → WebGL2 fallback, so the same TSL graph compiles to WGSL or GLSL depending on what the browser actually has. Real DOM text is CSS3D-projected onto each card rather than baked into the shader, which is also the scope line: the reference build's live cloth simulation — the grid physically wobbling under drag, with MSDF text deforming inside the WebGPU scene — is a second render pipeline on top of everything here, and this ships the rigid version instead.
Install
npm install three @react-three/fiber motionUsage
Drop it straight into a page.
import { LiquidGlassGrid } from "./LiquidGlassGrid";
export default function Example() {
return (
// The grid fills its nearest positioned ancestor (it renders itself
// `absolute inset-0`) — give it a sized, relatively positioned box.
<div className="relative h-[32rem] w-full overflow-hidden rounded-2xl">
<LiquidGlassGrid />
{/* Pure decoration behind other content — no drag surface, no
keyboard handling, just a slow constant drift. */}
{/* <LiquidGlassGrid interactive={false} /> */}
</div>
);
}Component
The real source, exactly as it ships — multiple files, kept together.
"use client";
/**
* An infinite grid of "liquid glass" cards — flat planes whose rounded
* bevel, chromatic refraction and fresnel reflection are entirely faked in
* a TSL shader (see `liquid-glass-grid/material.ts`), dragged across a
* patch of a sphere so the grid curves away instead of wrapping at a flat
* edge. Built from a Codrops write-up (Shader studio's WebGPU/TSL
* experiment) rather than reverse-engineered from a screenshot — the
* technique, not just the look, is what this reproduces.
*
* On its own detail page this is the actual content, not decoration behind
* copy — draggable, with real focus/keyboard handling rather than
* `aria-hidden`. `interactive={false}` (used where this rides behind other
* pages' copy, e.g. the Iris landing hero) drops all of that — no tabIndex,
* no drag surface — and replaces it with a slow constant drift so the field
* still reads as alive rather than a frozen screenshot.
*
* The renderer is real WebGPU via `three/webgpu` + `three/tsl`, run through
* React Three Fiber — not the raw-WebGL2 fragment-shader path every other
* field in this catalogue uses (`lib/shader-surface.ts`). TSL materials only
* run on `WebGPURenderer`, and that renderer already contains its own
* WebGPU → WebGL2 fallback (same TSL graph, compiled to WGSL or GLSL
* depending on what the browser actually has) — so the degradation this
* file owns is one level up: whether `WebGPURenderer` can initialise at
* all, on top of the usual reduced-motion and off-screen gating every field
* here follows.
*
* Scope cut, stated rather than silently dropped: the reference build's
* "Update" section adds a live XPBD cloth simulation (GPU compute passes)
* so the whole grid physically wobbles when dragged, with card labels
* baked as MSDF text inside the WebGPU scene so they can deform with the
* cloth. That's a second render pipeline (storage buffers, compute
* shaders, an MSDF font atlas) on top of everything here, for a portfolio
* piece whose point is the glass technique — this ships the rigid version
* (real DOM text, CSS3D-projected onto each card) and stops there.
*/
import { Component, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Canvas } from "@react-three/fiber";
import { motion, type PanInfo } from "motion/react";
import { GridManager, type DragState } from "./liquid-glass-grid/GridManager";
const KEY_STEP = 1.4;
/**
* Catches a `WebGPURenderer` that fails to initialise — a browser that
* advertises `navigator.gpu` but still can't actually stand up a device is
* a real, observed case, not a hypothetical, so this can't be a pre-flight
* check off to the side (a throwaway renderer probing "can this browser run
* WebGPU at all" answered that question differently than the real renderer
* did, in testing — the only trustworthy answer comes from the actual
* attempt). Falls back to the CSS floor, same as every other degradation
* path here.
*/
class GlassCanvasBoundary extends Component<{ onFail: () => void; children: ReactNode }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onFail();
}
render() {
return this.state.failed ? null : this.props.children;
}
}
export interface LiquidGlassGridProps {
className?: string;
/** Default `true`. Set `false` when this rides behind other content as
* pure decoration — see the file header. */
interactive?: boolean;
}
export function LiquidGlassGrid({ className, interactive = true }: LiquidGlassGridProps) {
const rootRef = useRef<HTMLDivElement>(null);
const cssContainerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState>({ x: 0, y: 0, vx: 0, vy: 0, dragging: false });
const [inView, setInView] = useState(false);
const [failed, setFailed] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
const [painted, setPainted] = useState(false);
const [cssContainer, setCssContainer] = useState<HTMLDivElement | null>(null);
// Off-screen is the common case on a catalogue page — same reasoning as
// `lib/shader-surface.ts`: don't spend a WebGPU/WebGL context on a piece
// nobody has scrolled to yet.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
rootMargin: "200px",
});
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const setCssContainerRef = useCallback((el: HTMLDivElement | null) => {
cssContainerRef.current = el;
setCssContainer(el);
}, []);
// Without this, a drag that happens to track back up over the title/lede
// overlay text starts the browser's own text-selection alongside the pan
// gesture — preventing default on the raw pointerdown (not the pan
// callback itself) is what actually suppresses that, since native
// selection is anchored at the original pointerdown event.
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
}, []);
const onPan = useCallback((_: unknown, info: PanInfo) => {
const drag = dragRef.current;
drag.dragging = true;
drag.x -= info.delta.x * 0.012;
drag.y += info.delta.y * 0.012;
}, []);
const onPanEnd = useCallback((_: unknown, info: PanInfo) => {
const drag = dragRef.current;
drag.dragging = false;
drag.vx = -info.velocity.x * 0.012;
drag.vy = info.velocity.y * 0.012;
}, []);
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
const drag = dragRef.current;
const map: Record<string, [number, number]> = {
ArrowLeft: [-KEY_STEP, 0],
ArrowRight: [KEY_STEP, 0],
ArrowUp: [0, KEY_STEP],
ArrowDown: [0, -KEY_STEP],
};
const delta = map[e.key];
if (!delta) return;
e.preventDefault();
drag.x += delta[0];
drag.y += delta[1];
drag.vx = 0;
drag.vy = 0;
}, []);
return (
<div
ref={rootRef}
className={`absolute inset-0 overflow-hidden outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[color:var(--iris-accent-bright)] focus-visible:outline-offset-[-3px]${className ? ` ${className}` : ""}`}
{...(interactive
? {
role: "group" as const,
"aria-label": "Interactive liquid glass grid — drag, or use the arrow keys, to explore",
tabIndex: 0,
onKeyDown,
}
: { "aria-hidden": true as const })}
>
<div
className="absolute inset-0 [background:radial-gradient(38%_30%_at_68%_74%,oklch(0.88_0.07_85_/_0.5)_0%,transparent_70%),linear-gradient(180deg,oklch(0.1_0.02_280)_0%,oklch(0.16_0.05_275)_42%,oklch(0.34_0.1_290)_64%,oklch(0.62_0.12_55)_84%,oklch(0.12_0.04_320)_100%)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(90deg,color-mix(in_oklab,white_10%,transparent)_0_2px,transparent_2px_15%),repeating-linear-gradient(0deg,color-mix(in_oklab,white_10%,transparent)_0_2px,transparent_2px_22%)] after:[-webkit-mask-image:radial-gradient(65%_60%_at_50%_46%,#000_0%,transparent_82%)] after:[mask-image:radial-gradient(65%_60%_at_50%_46%,#000_0%,transparent_82%)] after:opacity-50"
aria-hidden="true"
/>
{inView && !failed && (
<GlassCanvasBoundary onFail={() => setFailed(true)}>
<Canvas
className="absolute inset-0 opacity-0 transition-opacity duration-[700ms] ease-[ease] data-[shader=on]:opacity-100 motion-reduce:transition-none"
data-shader={painted ? "on" : undefined}
dpr={[1, 1.75]}
camera={{ position: [0, 0, 8.5], fov: 40, near: 0.1, far: 60 }}
frameloop={reducedMotion ? "demand" : "always"}
gl={async (props) => {
const { WebGPURenderer } = await import("three/webgpu");
const renderer = new WebGPURenderer({
canvas: props.canvas as HTMLCanvasElement,
antialias: true,
alpha: true,
});
try {
await renderer.init();
} catch (e) {
setFailed(true);
throw e;
}
return renderer;
}}
onCreated={() => setPainted(true)}
>
{cssContainer && (
<GridManager
dragRef={dragRef}
cssContainer={cssContainer}
reducedMotion={reducedMotion}
idleDrift={!interactive}
showLabels={interactive}
/>
)}
</Canvas>
<div ref={setCssContainerRef} className="absolute inset-0 z-[2] pointer-events-none" aria-hidden="true" />
{interactive && !reducedMotion && (
<motion.div
className="absolute inset-0 z-[3] cursor-grab [touch-action:none] active:cursor-grabbing"
onPointerDown={onPointerDown}
onPan={onPan}
onPanEnd={onPanEnd}
aria-hidden="true"
/>
)}
</GlassCanvasBoundary>
)}
</div>
);
}