Skip to content

IrisBackgroundsFolded Quilt

Folded Quilt

Nine folded fabric swatches fanned from an apex, each with its own print and colour.

drape

Folded Quilt

Nine patterned layers fan out from an apex above the frame, each seam a soft irregular chevron rather than a drawn line — the way cloth folds instead of creases. Every band carries its own print (pinstripe, polka dot, cross, scale, floral), scale and angle, from mustard through slate through burnt umber down to a near-black floral base, over an open teal ground.

The pointer presses a real dimple into the stack: seams near the cursor bow outward and each raised edge catches a warm sheen that tracks the touch.

  • warm-cool
  • fabric
Family
drape
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <QuiltField />
    </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 field of folded fabric swatches — nine patterned layers
 * fanned out from an apex above the frame, each seam a soft irregular
 * chevron rather than a drawn line, the way cloth folds instead of creases.
 * Every band carries its own print (pinstripe, polka dot, cross, scale,
 * floral), its own scale and angle, and a curated colour, so no two layers
 * read as the same fabric — mustard through slate through burnt umber down
 * to a near-black floral base, over an open teal ground where the apex
 * clears the top edge.
 *
 * The pointer presses a real dimple into the stack: seams near the cursor
 * bow outward — the same "fingers riffling a stack of swatches" read as
 * EmberField's sparks bending toward a hand or SilkField's folds leaning
 * toward it — and each seam's raised edge catches a warm sheen that tracks
 * the touch.
 *
 * One of the reusable background fields (`TileField`, `SilkField`,
 * `AuroraVeil`, `SpineField`, `SlabField`, `RakeField`, `FilamentField`,
 * `EmberField`, `ArcLightsField`, `CurrentField`, `RoadField`). 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-quiltfield__floor` underneath visible (a still
 * composed frame in the same palette, never a blank box).
 *
 * Like `EmberField` / `FilamentField` / `RakeField`, the palette is a fixed
 * set passed as uniforms rather than read from the token ramp — the quilted
 * warm/teal contrast lives nowhere in the accent ramp — so the CSS floor
 * carries the colour rather than a var().
 *
 * 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.
 */

const GROUND: [number, number, number] = [0.098, 0.412, 0.416]; // the teal the apex clears

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_ground;
uniform vec4 u_readA;
uniform float u_guard;

const int   BANDS = 9;
const float BAND_W = 0.125;
const float SLOPE  = 1.0;

vec2 rot(vec2 p, float a) {
  float c = cos(a), s = sin(a);
  return vec2(p.x * c - p.y * s, p.x * s + p.y * c);
}

/* Curated per-band colour, sRGB 0..1 — a cascade rather than a uniform
   array, so no dynamic array index is needed (same reasoning as
   ArcLightsField's u_light[0..2]: portable across older GLSL ES drivers). */
vec3 bandColor(float bi) {
  if (bi < 0.5) return vec3(0.831, 0.792, 0.631); // khaki pinstripe
  if (bi < 1.5) return vec3(0.552, 0.620, 0.651); // slate dot
  if (bi < 2.5) return vec3(0.867, 0.549, 0.169); // mustard cross
  if (bi < 3.5) return vec3(0.804, 0.682, 0.459); // tan pinstripe
  if (bi < 4.5) return vec3(0.522, 0.600, 0.643); // slate scale
  if (bi < 5.5) return vec3(0.329, 0.259, 0.153); // umber cross
  if (bi < 6.5) return vec3(0.800, 0.427, 0.161); // rust dot
  if (bi < 7.5) return vec3(0.890, 0.733, 0.310); // mustard dot
  return vec3(0.204, 0.141, 0.102);               // near-black floral, the widest layer
}

float stripeInk(vec2 p, float f) {
  return smoothstep(0.30, 0.85, abs(sin(p.x * f)));
}
float dotInk(vec2 p, float f) {
  vec4 c = dotCell(p * f);
  float r = mix(0.16, 0.26, dotHash(c.zw));
  return 1.0 - smoothstep(r * 0.65, r, length(c.xy));
}
float crossInk(vec2 p, float f) {
  vec4 c = dotCell(p * f);
  float d = min(abs(c.x), abs(c.y));
  return 1.0 - smoothstep(0.05, 0.15, d);
}
float scaleInk(vec2 p, float f) {
  float w = sin(p.x * f + sin(p.y * f * 0.55) * 1.7);
  return smoothstep(0.15, 0.75, w);
}
float floralInk(vec2 p, float f, out float accent) {
  vec4 c = dotCell(p * f);
  float h = dotHash(c.zw);
  float r = mix(0.10, 0.24, h) * (0.75 + 0.35 * fbm(c.zw * 2.3));
  float d = length(c.xy) - r;
  float ink = 1.0 - smoothstep(0.0, 0.10, d);
  accent = ink * step(0.5, h);
  return ink;
}

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

  vec2 apex = vec2(0.0, 0.58);
  vec2 ptr  = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toP  = P - ptr;
  float infl = u_pointer.z * exp(-dot(toP, toP) * 5.0);

  vec2 q  = P - apex;
  float d  = -q.y;
  float qx = q.x;

  /* soft irregular seams, so a boundary reads as folded cloth rather than a
     drawn line */
  qx += fbm(vec2(qx * 2.1, d * 1.3)) * 0.030;
  qx += fbm(vec2(qx * 5.4 + 12.0, d * 3.1)) * 0.010;
  qx += sin(u_time * 0.10 + d * 2.2) * 0.003;

  float v = d + abs(qx) * SLOPE;

  /* the touch presses a dimple into the stack — seams nearby bow outward,
     the way fingers riffling a stack of swatches lift the top few corners */
  v -= infl * BAND_W * 1.5;

  float bandF = v / BAND_W;
  float rawIndex = floor(bandF);
  float bandFrac = fract(bandF);
  float bi = clamp(rawIndex, 0.0, float(BANDS - 1));

  vec3 base = bandColor(bi);

  /* pattern, in the band's own rotated frame — each band gets a distinct
     scale and angle so no two read as the same fabric */
  float ang = 0.35 + dotHash(vec2(bi, 4.1)) * 2.4;
  vec2 pp = rot(P, ang) * mix(22.0, 46.0, dotHash(vec2(bi, 9.7)));

  float ink = 0.0;
  float accentMask = 0.0;
  vec3 accentCol = vec3(0.0);

  if (bi < 0.5)       ink = stripeInk(pp, 1.0);
  else if (bi < 1.5)  ink = dotInk(pp, 0.55);
  else if (bi < 2.5)  ink = crossInk(pp, 0.6);
  else if (bi < 3.5)  ink = stripeInk(pp, 1.4);
  else if (bi < 4.5)  ink = scaleInk(pp, 0.8);
  else if (bi < 5.5)  ink = crossInk(pp, 0.5);
  else if (bi < 6.5)  ink = dotInk(pp, 0.5);
  else if (bi < 7.5)  ink = dotInk(pp, 0.65);
  else {
    float acc;
    ink = floralInk(pp, 0.5, acc);
    accentMask = acc;
    accentCol = vec3(0.80, 0.47, 0.52);
  }

  float lum = dot(base, vec3(0.299, 0.587, 0.114));
  vec3 inkTint = lum > 0.52 ? base * 0.60 : mix(base, vec3(1.0), 0.55);
  inkTint = mix(inkTint, accentCol, accentMask);

  vec3 col = mix(base, inkTint, ink * 0.82);

  /* the fold's own shading: a soft cast shadow just past each seam, a thin
     raised highlight right before the next one */
  float shadow = 1.0 - smoothstep(0.0, 0.09, bandFrac);
  float rim    = smoothstep(0.90, 1.0, bandFrac);
  col = mix(col, col * 0.58, shadow * 0.55);
  col += vec3(1.0, 0.97, 0.92) * rim * (0.10 + infl * 0.22);

  /* the touch itself: a warm sheen brushing the fabric under the cursor */
  col += vec3(1.0, 0.92, 0.78) * infl * 0.07;

  /* the sliver above the apex, where the fold hasn't reached yet */
  col = rawIndex < 0.0 ? u_ground : col;

  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) ---- */
  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 guardBand = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), guardBand * u_guard);

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

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

export interface QuiltFieldProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved against `document`. `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
}

export function QuiltField({ className, guardSelector = null }: QuiltFieldProps) {
  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
          ? document.querySelector(guardSelector as string)
          : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: ["u_ground", "u_readA", "u_guard"],
      onInit: (gl, u) => {
        if (u.u_ground) gl.uniform3fv(u.u_ground, GROUND);
        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 the other fields: 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_800_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(_26%_15%_at_5%_0%,rgb(25,105,106)_0%,transparent_72%_),radial-gradient(_26%_15%_at_95%_0%,rgb(25,105,106)_0%,transparent_72%_),radial-gradient(_46%_26%_at_50%_-2%,rgb(212,202,161)_0%,transparent_68%_),radial-gradient(_60%_36%_at_50%_-2%,rgb(141,158,166)_0%,transparent_66%_),radial-gradient(_74%_46%_at_50%_-2%,rgb(221,140,43)_0%,transparent_64%_),radial-gradient(_88%_56%_at_50%_-2%,rgb(205,174,117)_0%,transparent_62%_),radial-gradient(_102%_66%_at_50%_-2%,rgb(133,153,164)_0%,transparent_60%_),radial-gradient(_116%_78%_at_50%_-2%,rgb(204,109,41)_0%,transparent_58%_),radial-gradient(_130%_92%_at_50%_-2%,rgb(230,186,79)_0%,transparent_56%_),rgb(45,31,23)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_35deg,transparent_0_9px,rgb(0_0_0_/_0.12)_9px_11px_),repeating-linear-gradient(_-35deg,transparent_0_9px,rgb(0_0_0_/_0.08)_9px_11px_)] after:[-webkit-mask-image:radial-gradient(_120%_90%_at_50%_-4%,transparent_0_8%,#000_30%,#000_100%_)] after:[mask-image:radial-gradient(_120%_90%_at_50%_-4%,transparent_0_8%,#000_30%,#000_100%_)]" />
      <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