IrisBackgroundsSilk Ribbon
Silk Ribbon
A single wide ribbon of silk falling through dark air, twisting about its own axis, alternating light and dark as it turns.
Silk Ribbon
A variation on `silk-drape` that trades a hung sheet for a single falling ribbon: it sways side to side as it drops and twists about its own long axis, the visible face cycling from front to back every half-turn — foreshortening to a near-hairline edge-on, opening back to full width face-on, catching a soft specular streak right as it turns toward the viewer. Open dark air surrounds it rather than a full-bleed sheet of fabric.
The pointer doesn't part anything here — there's no sheet to part. It catches the ribbon instead: presence near the cursor's own height adds a flutter to the sway and speeds the twist locally, and a bright glint lands exactly where the cursor meets the ribbon's surface.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { SilkRibbonField } from "./SilkRibbonField";
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">
<SilkRibbonField />
</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 variation on `silk-drape`'s drape family: not a sheet of
* hung fabric, but a single wide ribbon falling through open dark air,
* swaying side to side and twisting about its own long axis as it drops.
* The twist is what reads as motion through 3D rather than a flat texture
* animating — the visible face cycles from front to back every half-turn,
* foreshortening to a near-hairline edge-on and opening back out to full
* width face-on, catching a soft specular streak right as it turns toward
* the viewer.
*
* The pointer doesn't part anything here — there's no sheet to part. It
* catches the ribbon instead: presence near the cursor's own height adds a
* flutter to the sway and speeds the twist locally, and a bright glint
* lands exactly where the cursor meets the ribbon's surface, the way a
* finger brushing a falling ribbon would catch the light off it.
*
* One of the reusable background fields, a sibling of `SilkField`,
* `SilkCoilField` and `SilkPoolField` in the same fabric family — and
* itself the base of two fixed-palette siblings, `RoseRibbonField` and
* `IndigoRibbonField`, which keep this exact geometry and pointer contract
* with a different ribbon colour. 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-silkribbonfield__floor` underneath visible.
*
* Same reasoning as `SilkField` for the palette: it IS the site's amber
* accent ramp, read live via `colors`, not a fixed departure from it.
*
* Reading guard: identical mechanism to `SilkField` — off by default
* (`null`), on when `guardSelector` resolves to an element.
*/
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; /* the ambient ground the ribbon falls through */
uniform vec3 u_dark; /* the ribbon's shaded, turned-away face */
uniform vec3 u_light; /* the ribbon's lit, face-on side */
uniform vec3 u_hot; /* the near-white glint / specular */
uniform vec4 u_readA;
uniform float u_guard;
const float SWAY_FREQ = 0.9;
const float SWAY_AMP = 0.30;
const float TWIST_RATE = 5.5;
const float RIBBON_W = 0.30;
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);
/* the pointer catches the ribbon: a flutter localised to its own
height, not a directional pull the way silk-drape's touch works */
float flutter = u_pointer.z * exp(-pow((uv.y - u_pointer.y) * 4.0, 2.0));
float sway = sin(uv.y * SWAY_FREQ * 6.2831853 + u_time * 0.12) * SWAY_AMP
+ fbm(vec2(uv.y * 2.2, u_time * 0.05)) * 0.05
+ flutter * sin(u_time * 7.0 + uv.y * 40.0) * 0.10;
float centerX = sway;
/* the twist about the ribbon's own long axis as it falls — this is
"alternating light and dark as it turns": the face the viewer sees
cycles front to back, and the visible width foreshortens with it,
the way a real ribbon spinning as it drops actually reads */
float twist = uv.y * TWIST_RATE + u_time * 0.35 * (1.0 + flutter * 1.6);
float facing = cos(twist);
float halfW = max(RIBBON_W * abs(facing), 0.004);
float dx = P.x - centerX;
float edge = abs(dx);
float inRibbon = 1.0 - smoothstep(halfW * 0.86, halfW, edge);
float fold = sin((dx / halfW) * 3.14159265 * 4.5 + uv.y * 3.0);
vec3 faceCol = mix(u_dark, u_light, smoothstep(-0.15, 0.35, facing));
faceCol *= 0.78 + 0.30 * (0.5 + 0.5 * fold);
/* a specular streak riding the centre as the ribbon turns face-on */
float spec = smoothstep(0.55, 1.0, facing) * (1.0 - smoothstep(0.0, halfW * 0.5, edge));
faceCol += u_hot * spec * 0.5;
/* rim light at the curled edge, brighter on the side turned toward us */
float rim = exp(-max(edge - halfW * 0.7, 0.0) * 60.0) * smoothstep(-0.2, 0.4, facing);
faceCol += u_light * rim * 0.35;
vec3 bg = u_deep * (0.88 + 0.12 * (fbm(vec2(uv.x * 2.6, uv.y * 1.2 + u_time * 0.015)) * 0.5 + 0.5));
vec3 col = mix(bg, faceCol, inRibbon);
/* the touch: a bright glint exactly where the cursor meets the ribbon's
surface, in place of the family's usual bloom-at-the-cursor */
float distPtr = length(P - ptr);
float onRibbonAtPtr = 1.0 - smoothstep(halfW, halfW * 1.7, abs(ptr.x - centerX));
col += u_hot * u_pointer.z * onRibbonAtPtr * exp(-distPtr * distPtr * 60.0) * 0.6;
col = max(col, 0.0);
/* ---- the reading guard (see SilkField 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 SilkRibbonFieldProps {
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 SilkRibbonField({ className, guardSelector = null }: SilkRibbonFieldProps) {
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_dark: "--color-accent-800",
u_light: "--color-accent-400",
u_hot: "--color-accent-100",
},
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 — same contract as SilkField: once painted, the last
frame stays on screen while the surface is parked off-view. */
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:radial-gradient(16%_88%_at_47%_46%,var(--color-accent-400)_0%,transparent_62%),var(--color-accent-950)]" />
<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>
);
}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
Reads and answers every request himself