Skip to content

Silk Drape

Backlit silk in one warm amber gradient, its folds parting toward the pointer.

Soft vertical drapery lit from behind, in one amber gradient — deep umber at the top thinning to pale gold at the base. The palette is the site's own accent ramp, read live, so a theme change or context restore picks it up automatically.

The pointer parts the folds: the drapery nearest the cursor bends toward it and catches a soft bloom, as if the fabric were touched from behind.

Details

Kind
Drape
Stack
React · TypeScript
Depends on
Nothing
Licence
Free
Added
11 Sep 2026
Tags
warm, fabric, token-palette

Its light

  • 17%
  • 19%
  • 25%
  • 20%
  • 19%

Takes its colours from your site’s accent ramp — measured here as it renders on Iris.

Code

Install

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

Usage

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

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">
      <SilkField />
    </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 soft vertical drapery — backlit silk in one warm
 * amber gradient, deep umber at the top thinning to pale gold at the base.
 * The pointer parts the folds: the drapery nearest the cursor bends toward
 * it and catches a soft bloom, as if the fabric were being touched from
 * behind.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the box.
 * Built on `lib/shader-surface.ts`, so degradation is already handled: no
 * WebGL, a blocked or lost context, a hidden tab, or `prefers-reduced-motion`
 * all leave the CSS `.iris-silkfield__floor` underneath visible.
 *
 * Unlike `TileField`, the palette here IS the site's amber accent ramp
 * (`--color-accent-950` … `--color-accent-300`, read live via `colors` so a
 * theme change or context restore picks it up automatically) — the "same
 * colour" the reference asked for is the portfolio's own amber, not a new
 * fixed one.
 *
 * Reading guard: same mechanism as `TileField` — when `guardSelector`
 * resolves to an element, the field measures it every frame and clamps its
 * own luminance under a ceiling in that region (hue/saturation untouched).
 * Off by default (`null`) because this field, unlike the hero's, is meant to
 * sit behind anything — pass a selector where copy overlaps it.
 */
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_deep;    /* --color-accent-950, the top of the field */
uniform vec3 u_umber;   /* --color-accent-800, upper-mid */
uniform vec3 u_gold;    /* --color-accent-600, lower-mid */
uniform vec3 u_pale;    /* --color-accent-300, the base */

uniform vec4  u_readA;
uniform float u_guard;

/* Folds across the width, at two scales — a slow, wide sway and a finer
   thread count riding on top of it. What reads as silk rather than
   stripes is the second scale plus the domain warp below. */
const float FOLDS   = 6.0;
const float THREADS = 22.0;

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);

  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toP = P - ptr;
  float dP = length(toP);
  float pull = u_pointer.z * exp(-dP * dP * 50.0);

  /* the touch parts the drapery: folds near the cursor bend toward it,
     as if the fabric were being pulled from behind the cursor */
  vec2 wp = P - toP * pull * 1.1;

  /* a slow, low-amplitude warp gives the folds their waviness without
     smearing the banding away — its own spatial frequency stays well
     below the fold frequency it perturbs */
  float warp = fbm(vec2(wp.x * 1.4, wp.y * 0.8 + u_time * 0.02)) * 0.30;
  float phase = wp.x * FOLDS + warp + u_time * 0.035;

  float coarse = sin(phase * 3.14159265);
  float fine   = sin((wp.x * THREADS + warp * 1.6) * 3.14159265);
  float drape  = clamp(coarse * 0.72 + fine * 0.28, -1.0, 1.0);
  drape = sign(drape) * pow(abs(drape), 0.82);         /* widen the bright peaks a touch */

  /* ---- the vertical gradient, warm at the top, pale at the base ---- */
  vec3 base = u_pale;
  base = mix(base, u_gold,  smoothstep(0.10, 0.52, uv.y));
  base = mix(base, u_umber, smoothstep(0.42, 0.80, uv.y));
  base = mix(base, u_deep,  smoothstep(0.70, 1.05, uv.y));

  vec3 col = base * (0.58 + 0.62 * (0.5 + 0.5 * drape));

  /* the touch: a soft warm bloom, and the nearest folds catch extra light */
  col += u_pale * pull * 0.42;
  col += base   * pull * (0.5 + 0.5 * drape) * 0.5;

  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 band = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), band * u_guard);

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

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

export interface SilkFieldProps {
  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 SilkField({ className, guardSelector = null }: SilkFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

    const guardOn = guardSelector != null;
    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,
      colors: {
        u_deep: "--color-accent-950",
        u_umber: "--color-accent-800",
        u_gold: "--color-accent-600",
        u_pale: "--color-accent-300",
      },
      uniforms: ["u_readA", "u_guard"],
      onInit: (gl, u) => {
        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: once the field has painted once, its last frame stays on
         screen while the surface is parked off-view. Removing data-shader
         here (as TileField once did) crossfades the canvas back to the
         static __floor gradient on every scroll-away and then fades the live
         field back in over 600ms on return — the "static then shader" flash.
         The floor is a fallback for no-WebGL / lost-context / reduced-motion
         only; a lost context still clears it below. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`} aria-hidden="true">
      <div className="absolute inset-0 [background:linear-gradient(_180deg,var(--color-accent-950)_0%,var(--color-accent-800)_42%,var(--color-accent-600)_72%,var(--color-accent-300)_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>
  );
}

Nearest in light

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