Skip to content

IrisBackgroundsStone Slabs

Stone Slabs

Polished agate slabs in a grouted lattice, veining sharpening under the cursor.

tile

Stone Slabs

Axis-aligned rounded tiles, each cut from a different part of the same agate block, laid over a colour field that runs teal at the top-left, into a dark valley through the centre, out to rust and amber down the right. Thin near-black grout runs between them with a soft contact shadow.

The slabs under the cursor swell slightly, their veining sharpens as if the dust were wiped off, and a specular sheen rakes across them like light on wet stone. With no pointer, a slow diagonal sweep keeps the field breathing.

  • warm-cool
  • lattice
Family
tile
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
import { SlabField } from "./SlabField";

export default function Example() {
  return (
    // Fills its nearest positioned ancestor (it renders itself `absolute
    // inset-0`) — give it a sized, relatively positioned box.
    <div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
      {/* Defaults to guarding ".iris-hero__inner" (this site's own hero
          layout) — pass your own selector, or null, outside it. */}
      <SlabField guardSelector={null} />
    </div>
  );
}

Component

The real source, exactly as it ships — multiple files, kept together.

"use client";

import { useEffect, useRef } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";

/**
 * A live WebGL lattice of polished-stone slabs — axis-aligned rounded tiles,
 * each one cut from a different part of the same agate block, laid over a
 * colour field that runs teal at the top-left, into a dark valley through
 * the centre, out to rust and amber down the right and bottom-right. Thin
 * near-black grout runs between the tiles with a soft contact shadow.
 *
 * The pointer is real: the slabs under the cursor swell slightly, their
 * veining sharpens as if the dust were wiped off, and a soft specular sheen
 * rakes across them like light on wet stone. When nobody is pointing, a very
 * slow diagonal light sweep keeps the field breathing.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the box.
 * Built on `lib/shader-surface.ts`, so every degradation path is already
 * handled: no WebGL, a blocked or lost context, a hidden tab, or
 * `prefers-reduced-motion` all leave the CSS `.iris-slabfield__floor`
 * underneath visible (a still composed frame in the same palette, never a
 * blank box). The one frame a reduced-motion visitor gets is drawn by the
 * surface at a fixed time with the pointer withdrawn.
 *
 * Like `TileField` and `AuroraVeil` (and unlike `SilkField`), the palette is
 * a fixed teal/rust set passed as uniforms rather than read from the token
 * ramp — these hues live nowhere in the token set, so the CSS floor is the
 * degradation fallback instead of a live token read.
 *
 * Reading guard: when `guardSelector` resolves to an element, the field
 * measures that block every frame and clamps its own luminance under a
 * ceiling inside that region (hue and saturation untouched), so copy laid
 * over the field holds WCAG AA with no overlay layer. `null` (the default)
 * turns the guard off — for decorative use where nothing sits on top.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_grout: [0.035, 0.045, 0.048],
  u_teal: [0.106, 0.271, 0.259],
  u_deep: [0.031, 0.055, 0.063],
  u_rust: [0.412, 0.216, 0.122],
  u_amber: [0.745, 0.435, 0.204],
  u_vein: [0.878, 0.847, 0.808],
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3  u_pointer;   /* x, y (0 bottom .. 1 top), presence 0..1 */

uniform vec3 u_grout;   /* the near-black seam between the slabs */
uniform vec3 u_teal;    /* the stone at the top-left */
uniform vec3 u_deep;    /* the dark valley through the centre */
uniform vec3 u_rust;    /* the stone down the right side */
uniform vec3 u_amber;   /* the warm glow in the bottom-right corner */
uniform vec3 u_vein;    /* warm-white — the veining and edge bevels */

/* Reading region for the contrast guard: xy = centre (uv, y up),
   zw = half-extent. Measured from the live copy block every frame.
   u_guard is 0 when the guard is off. */
uniform vec4  u_readA;
uniform float u_guard;

/* Slabs per unit of centred, aspect-corrected space. One line to retune
   the density of the lattice. */
const float GRID = 7.5;

/* Signed distance to a rounded box, zero on the edge, negative inside. */
float sdRoundBox(vec2 p, vec2 b, float r) {
  vec2 d = abs(p) - b + r;
  return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - r;
}

float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }

/* Polished-stone body: two-step domain-warped fbm. Reads as agate veining
   rather than cloud — the final sample is pushed around by an earlier one,
   so the light bands fold back on themselves. Three fbm calls, deliberately
   — the slab wants a shape that is not an ellipse, not a texture worth
   studying up close. */
float marble(vec2 p, float t) {
  vec2 q = vec2(fbm(p), fbm(p + vec2(3.4, 1.7)));
  return fbm(p + 3.5 * q + vec2(0.0, t));
}

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;          /* 0..1, y up */
  float aspect = res.x / res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);             /* centred, square units */

  /* ---- the colour field ---------------------------------------------
     A diagonal from teal (top-left) to rust (bottom-right), a warm amber
     lift in the bottom-right corner, and a drifting Gaussian well that
     digs the dark valley near the centre. */
  float diag = uv.x - uv.y;                            /* -1 top-left .. 1 bottom-right */

  vec3 field = u_teal;
  field = mix(field, u_rust, smoothstep(-0.4, 0.65, diag));
  field = mix(field, u_amber,
              smoothstep(0.2, 1.0, uv.x) * smoothstep(0.62, 0.0, uv.y) * 0.85);

  vec2 vc  = vec2(0.46 + 0.015 * sin(u_time * 0.11),
                  0.5  + 0.015 * cos(u_time * 0.09));
  vec2 dcv = (uv - vc) * vec2(1.15, 1.0);
  float valley = exp(-dot(dcv, dcv) * 4.5);
  field = mix(field, u_deep, valley * 0.94);

  field += u_teal  * 0.10 * smoothstep(0.6, 0.0, uv.x) * smoothstep(0.45, 1.0, uv.y);
  field += u_amber * 0.12 * smoothstep(0.55, 1.0, uv.x) * smoothstep(0.5, 0.0, uv.y);
  float fLuma = luma(field);

  /* ---- the slab lattice -------------------------------------------- */
  vec2 g   = P * GRID;
  vec2 cid = floor(g);
  vec2 fp  = fract(g) - 0.5;
  float rnd = dotHash(cid);

  vec2 ptrP  = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 tileC = (cid + 0.5) / GRID;                     /* slab centre, P units */
  float hover = u_pointer.z * exp(-dot(tileC - ptrP, tileC - ptrP) * 18.0);

  float fill = 0.90 + 0.05 * hover;                    /* slabs swell under the cursor */
  float d    = sdRoundBox(fp, vec2(0.5 * fill), 0.16 * fill);
  float aa   = 1.4 * GRID / res.y;                     /* ~1.4 device px, in cell units */
  float tile = 1.0 - smoothstep(-aa, aa, d);

  /* the stone body — each slab samples a distinct 1.4-unit window of the
     marble field, at a slightly different scale, so no two are cut alike */
  vec2 stoneUV = fract(g) * (1.7 + 0.9 * rnd)
               + vec2(rnd * 40.0, dotHash(cid + 7.0) * 40.0);
  float slowT  = u_time * 0.015;
  float mm     = marble(stoneUV + slowT, slowT);
  /* the pointer sharpens the veining, as if the dust were wiped off */
  float polish = 0.30 + 0.70 * hover;
  float veins  = pow(smoothstep(0.18, 0.6, mm), mix(1.1, 2.6, polish));
  float grain  = vnoise(P * 220.0) * 0.05;

  vec3 stone = field * (0.64 + 0.52 * mm + 0.16 * (rnd - 0.5));
  stone *= 0.68 + 0.62 * rnd;                          /* per-slab value scatter */
  stone += u_vein * veins * (0.05 + 0.14 * fLuma + 0.22 * hover);
  stone *= 1.0 + grain;

  /* per-slab lighting: top-lit, a soft central dome, a bright inner bevel */
  float dome  = smoothstep(0.5, 0.0, dot(fp, fp) * 1.8);
  float lit   = 0.72 + 0.30 * (fp.y + 0.5) + 0.12 * dome;
  float bevel = smoothstep(0.06, 0.0, abs(d + 0.02));
  stone *= lit;
  stone += u_vein * bevel * (0.04 + 0.07 * fLuma);

  /* grout — near-black, darkened into a contact shadow right beside each slab */
  float ao   = 1.0 - smoothstep(0.015, 0.22, d);
  vec3 grout = u_grout * (1.0 - 0.55 * ao) * (0.7 + 0.5 * fLuma);

  vec3 col = mix(grout, stone, tile);

  /* ---- pointer sheen: a soft highlight raking the slabs under the cursor */
  float sheen = u_pointer.z * exp(-dot(P - ptrP, P - ptrP) * 10.0);
  col += u_vein * sheen * tile * (0.10 + 0.16 * veins);
  col = mix(col, stone * 1.22, tile * sheen * 0.32);

  /* ---- pointer glint in the seams: a white shine that runs the grout
     lines and pools where four slabs meet, only under the cursor */
  float near    = u_pointer.z * exp(-dot(P - ptrP, P - ptrP) * 15.0);
  float edgeLit = (1.0 - tile) * smoothstep(0.15, 0.0, max(d, 0.0));
  vec2  nOff    = fract(g + 0.5) - 0.5;
  float node    = exp(-dot(nOff, nOff) * 60.0) * (1.0 - tile);
  float shimmer = 0.72 + 0.28 * sin(u_time * 2.2 + (uv.x + uv.y) * 30.0);
  col += u_vein * near * (edgeLit * 0.55 * shimmer + node * 1.2);

  /* ---- a very slow diagonal light sweep, so the idle field breathes */
  float sweep = sin((uv.x + uv.y) * 2.4 - u_time * 0.22);
  col += col * smoothstep(0.72, 1.0, sweep) * 0.05 * tile;

  col = max(col, 0.0);

  /* ---- the reading guard -------------------------------------------
     See TileField for the full rationale: where the copy sits, pull the
     field's luminance under a ceiling so text holds WCAG AA. holdUnder()
     clamps brightness in linear light and leaves hue and saturation alone —
     the field dims itself, there is no overlay layer. */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float band = 1.0 - smoothstep(0.72, 2.1, m);
  band = max(band, smoothstep(0.8, 1.0, uv.y) * 0.85);
  col = mix(col, holdUnder(col, 0.09), band * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.0 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface SlabFieldProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved within the nearest `.iris-hero` (falling back to the
   * document). `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
}

export function SlabField({ className, guardSelector = null }: SlabFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;

    /* The block the reading guard protects. Looked up once, lazily. */
    let guardEl: Element | null | undefined;
    const readGuardEl = () => {
      if (guardEl === undefined) {
        guardEl = guardOn
          ? (canvas.closest(".iris-hero") ?? document).querySelector(
              guardSelector as string
            )
          : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard"],
      onInit: (gl, u) => {
        for (const name of Object.keys(PALETTE)) {
          if (u[name]) gl.uniform3fv(u[name], PALETTE[name]);
        }
        if (u.u_readA) gl.uniform4f(u.u_readA, 0.5, 0.5, 0.44, 0.32);
        if (u.u_guard) gl.uniform1f(u.u_guard, guardOn ? 1 : 0);
      },
      onFrame: (gl, u, s) => {
        if (!guardOn || !u.u_readA) return;
        let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
        const el = readGuardEl();
        const { rect } = s;
        if (el && rect.width > 0 && rect.height > 0) {
          const r = el.getBoundingClientRect();
          const padX = rect.width * 0.09;
          const padY = rect.height * 0.11;
          cx = (r.left + r.width / 2 - rect.left) / rect.width;
          cy = 1 - (r.top + r.height / 2 - rect.top) / rect.height;
          hw = (r.width / 2 + padX) / rect.width;
          hh = (r.height / 2 + padY) / rect.height;
        }
        gl.uniform4f(u.u_readA, cx, cy, hw, hh);
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      /* No onIdle — same contract as SilkField/AuroraVeil: once painted, the
         last frame stays on screen while the surface is parked off-view, so
         scrolling away and back does not crossfade to the static floor. A
         lost context still clears it below. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 1_900_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_120%_100%_at_8%_88%,oklch(0.46_0.06_178)_0%,transparent_52%_),radial-gradient(_120%_110%_at_96%_20%,oklch(0.5_0.11_52)_0%,transparent_55%_),radial-gradient(_110%_120%_at_92%_96%,oklch(0.62_0.13_58)_0%,transparent_52%_),radial-gradient(_70%_60%_at_45%_50%,oklch(0.16_0.03_200)_0%,transparent_74%_),oklch(0.14_0.02_200)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_0deg,transparent_0_92px,oklch(0.08_0.02_200_/_0.7)_92px_104px_),repeating-linear-gradient(_90deg,transparent_0_92px,oklch(0.08_0.02_200_/_0.7)_92px_104px_)]" />
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
    </div>
  );
}

More backgrounds

Custom work

Need one that isn't in the catalogue?

Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.

Fardin Omor Afnan

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections