Skip to content

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.

  • webgpu
  • tsl
  • three.js
  • react-three-fiber
  • shader
Status
Available
Licence
Free

Install

npm install three @react-three/fiber motion

Usage

Drop it straight into a page.

example.tsx
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>
  );
}

More components

Start with a background.

The backgrounds are the most finished corner of the catalogue. Take one and drop it under your own copy.

Browse backgrounds