This is the whole build of the silk menu, in the order it was written: the Collections menu of a made-up fashion house, Maison Élan. The dropdown is not a box; it is a sheet of champagne silk pinned to the nav line. It pours out when the menu opens, a ripple runs through it from the row under your pointer, a press dents it, and choosing a collection draws it back up into the nav. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework and no build step: one HTML page, one style sheet and four ES modules. Every word on the silk is real DOM; the cloth is one WebGL2 canvas under it.
1 · The menu, before the silk
Start with what a screen reader and a keyboard need: the disclosure pattern. Collections is a real button that says whether the menu is open and which list it controls, and the menu is a list of real links.
<button type="button" id="coll-btn" class="coll-btn" aria-expanded="true" aria-controls="silk-menu">
<span>Collections</span>
<svg class="chev" viewBox="0 0 10 6" aria-hidden="true" focusable="false"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="silk-menu" id="silk-menu">
<ul id="rows" aria-label="Collections"></ul>
</div>
The print you see is a separate, decorative copy of the links' text, and every glyph is its own span. That costs nothing now and is what lets stage 2 bend each letter with the cloth. Each name is printed twice, roman and italic, so hover is a cross-fade on the compositor instead of a font change and a relayout.
const blk = (cls, text, glyphs = true) => {
const d = document.createElement('div'); d.className = 'blk ' + cls;
if (glyphs) { for (const ch of text) { if (ch === ' ') { d.appendChild(document.createTextNode(' ')); continue; } const s = document.createElement('span'); s.className = 'g'; s.textContent = ch; d.appendChild(s); } }
else { const s = document.createElement('span'); s.className = 'g'; s.textContent = text; d.appendChild(s); }
ink.appendChild(d); return d;
// the name is printed twice, roman and italic; hover / current cross-fades them on the compositor (no relayout, no raster)
const mk = (cls) => { const lab = document.createElement('div'); lab.className = 'blk lab ' + cls;
for (const ch of it.t) { if (ch === ' ') { lab.appendChild(document.createTextNode(' ')); continue; } const s = document.createElement('span'); s.className = 'g'; s.textContent = ch; lab.appendChild(s); }
r.appendChild(lab); return lab; };
.lab.ita { font-style: italic; color: var(--ink-on); }
.lab.ita .g { opacity: 0; }
For now the print lies flat on a frosted silk swatch drawn in CSS, and one transform puts all of it in place.
.swatch { display: none; position: fixed; left: 0; top: 0; z-index: 3; pointer-events: none; border-radius: 0 0 3px 3px;
background:
repeating-linear-gradient(90deg, rgba(255, 252, 244, 0) 0, rgba(255, 252, 244, .5) 7%, rgba(255, 252, 244, 0) 12%, rgba(120, 100, 70, .14) 17%, rgba(255, 252, 244, 0) 21%),
linear-gradient(180deg, rgba(241, 230, 208, .94), rgba(232, 220, 196, .94));
-webkit-backdrop-filter: blur(14px) saturate(1.1); backdrop-filter: blur(14px) saturate(1.1);
box-shadow: 26px 34px 60px rgba(0, 0, 0, .5), inset 0 -10px 0 rgba(150, 120, 80, .16); }
function placeInk() {
if (!L) return;
// flat: the whole print sits on the swatch by one translate (stage 2 gives every glyph the cloth's local affine)
const tf = `translate(${(L.cx - L.W / 2).toFixed(2)}px,${L.railY.toFixed(2)}px)`;
for (const g of G) {
const op = g.it < 0 ? 1 : g.it ? ital[g.row] : 1 - ital[g.row]; // a name is printed roman and italic: show one
if (tf !== g.tf) { g.el.style.transform = tf; g.tf = tf; }
if (op !== g.op) { g.el.style.opacity = op; g.op = op; }
}
for (const a of rows) a.style.transform = tf;
}
The keyboard works the way a menu should: arrows move and wrap, Home and End jump, typing a letter finds the next collection, Esc closes and gives focus back to Collections. The focused row gets a dashed ring.
menu.addEventListener('keydown', (e) => {
const i = rows.indexOf(document.activeElement); if (i < 0) return;
let j = -1;
if (e.key === 'ArrowDown') j = (i + 1) % rows.length;
else if (e.key === 'ArrowUp') j = (i - 1 + rows.length) % rows.length;
else if (e.key === 'Home') j = 0;
else if (e.key === 'End') j = rows.length - 1;
else if (e.key === 'Escape') { e.preventDefault(); close({ toButton: true }); return; }
else if (e.key === ' ') { e.preventDefault(); rows[i].click(); return; }
else if (e.key.length === 1 && /\S/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
const now = performance.now(); typed = (now - typedT < 700 ? typed : '') + e.key.toLowerCase(); typedT = now;
for (let k = 1; k <= rows.length; k++) { const c = (i + (typed.length > 1 ? 0 : k)) % rows.length; if (ITEMS[c].t.toLowerCase().startsWith(typed)) { j = c; break; } }
}
if (j >= 0) { e.preventDefault(); rows[j].focus(); }
});
.row:focus-visible { outline: 1.5px dashed var(--red); outline-offset: -5px; }
Choosing a collection moves aria-current, announces it in a live region, updates the hero line and
closes the menu a moment later.
function select(i) {
if (i === DISABLED) { announce(`${ITEMS[i].t} opens soon.`); return; }
current = i; setCurrentAttr();
// storage, the live region and the hero line wait a beat: the click's own frame only moves the silk
setTimeout(() => {
announce(`${ITEMS[i].t} collection, ${ITEMS[i].pieces} pieces.`);
eyebrow.textContent = 'Autumn · Winter 26 · ' + ITEMS[current].t;
}, 140);
for (let r = 0; r < ITEMS.length; r++) remeasureRow(r);
placeInk();
const inside = menu.contains(document.activeElement);
clearTimeout(closeTimer);
closeTimer = setTimeout(() => close({ toButton: inside }), reduced ? 260 : 720);
}

2 · The silk
One transparent canvas goes under the print. It covers only the silk's box, not the page.
<canvas id="silk" aria-hidden="true"></canvas>
The silk is a verlet cloth: 25 × 33 particles, each remembering where it was a step ago. Structural and shear links hold the weave together; bend links two particles apart give it body.
if (i < NX - 1) L.push(k0, idx(i + 1, j), sx, 0);
if (j < NY - 1) L.push(k0, idx(i, j + 1), sy, 0);
if (i < NX - 1 && j < NY - 1) { const d = Math.hypot(sx, sy); L.push(k0, idx(i + 1, j + 1), d, 0, idx(i + 1, j), idx(i, j + 1), d, 0); }
if (i < NX - 2) L.push(k0, idx(i + 2, j), sx * 2 * 0.985, 1);
if (j < NY - 2) L.push(k0, idx(i, j + 2), sy * 2, 2);
The top row is pinned to the nav line closer together than the cloth is wide, so it gathers into pleats. Their depth is chosen so a pleat's arc length is exactly the width it gathers.
// pleat amplitude so a pleat's arc length equals the flat width it gathers
const k = 2 * Math.PI / (lam * sx * gather);
this.amp = Math.sqrt(Math.max(0, 4 * (1 / gather - 1))) / k * 0.92;
Verlet integration is three lines: the new position is the old one plus the last step's motion, damped, plus the forces.
let px = x + (x - O[q]) * damp + ax * dt2;
let py = y + (y - O[q + 1]) * damp + g * dt2;
let pz = z + (z - O[q + 2]) * damp + az * dt2;
The trick that makes it silk and not rubber: bend links may shorten, so the cloth folds, but never below a floor, so a fold stays soft instead of collapsing into a crease. A tether keeps every particle within cloth's reach of its pin, because silk does not stretch.
if (kind !== 0 && d2 < L * L) { // bend links: free to fold, but a fold may not collapse flat
const m = kind === 2 ? cm[c] * comp : cm[c];
if (d2 >= m * m) continue;
const d = Math.sqrt(d2) || 1e-6; f = (d - m) / d * 0.5;
} else { const d = Math.sqrt(d2) || 1e-6; f = (d - L) / d; }
// tethers: no particle may be further from its pin than the cloth between them
const sy = this.sy;
for (let j = 1; j < NY; j++) {
const Lm = j * sy * 1.004;
for (let i = 0; i < NX; i++) {
const q = (j * NX + i) * 3, p = i * 3;
const dx = P[q] - P[p], dy = P[q + 1] - P[p + 1], dz = P[q + 2] - P[p + 2];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 > Lm * Lm) { const d = Math.sqrt(d2), f = (d - Lm) / d; P[q] -= dx * f; P[q + 1] -= dy * f; P[q + 2] -= dz * f; }
}
}
To open, the cloth starts folded into a soft concertina stacked behind the nav line. Every vertical link keeps its rest length, so the silk pours out through the rail and unfolds without tangling. Heavier gravity at first, then a gentle pull back to its pressed pleats so it always lands clean.
// fold: a soft concertina (sinusoidal pleats, 8 rows per pleat) stacked up behind the nav line; every vertical link
// keeps its rest length, so the silk pours out through the rail and unfolds without tangling
const FY = new Float32Array(NY), FZ = new Float32Array(NY);
const A = 0.9 * sy * 8 / (2 * Math.PI);
for (let j = 1; j < NY; j++) {
FZ[j] = A * Math.sin(2 * Math.PI * j / 8);
const dz = FZ[j] - FZ[j - 1];
FY[j] = FY[j - 1] + Math.sqrt(Math.max(0, sy * sy - dz * dz));
}
if (S.phase === 'opening') {
cloth.g = age < 0.3 ? 2600 : age < 0.9 ? 2600 - (age - 0.3) / 0.6 * 1200 : 1400;
cloth.compress = Math.min(1, Math.max(0, (age - 0.95) / 0.6));
// after the pour, the silk finds its pressed pleats again (resolves any flap that landed folded over)
// (a flap still folded over at 2.4 s keeps the pull on, up to 4 s)
if (age > 1.8 && age < 4 && S.mendT === 0 && cloth.deviation() > 14 * L.s) S.mendT = age;
const end = S.mendT ? 4 : 2.4;
cloth.memory = age < 0.85 ? 0 : 0.006 + 0.07 * Math.min(1, (age - 0.85) / 0.3) * Math.min(1, Math.max(0, (end - age) / 0.6));
if (age > end) { S.phase = 'open'; cloth.g = 1400; cloth.memory = 0.006; }
}
Closing lifts the rail itself, faster and faster, and the silk flutters up into the nav line.
const k = Math.min(1, age / 0.62);
cloth.lift = (L.H + 160 * L.s) * k * k * (1.4 - 0.4 * k);
cloth.g = 1400;
if (k >= 1) { finishClose(); return; }
Hovering a row sends a ripple front up and down the cloth from that row; a press pushes the silk back under the pointer and the print is pressed in with it.
if (!reduced && S.fx && S.phase !== 'closing') { cloth.ripple(rowV(i), 1); S.lastInput = performance.now(); wake(); }
for (let r = 0; r < nr; r++) {
const e = (Math.abs(v - rv[r]) - rf[r]) * 16.667;
if (e > 3 || e < -3) continue;
az += ra[r] * Math.exp(-e * e) * rs[r * NX + i];
}
if (S.fx && !reduced) { cloth.capturePress(); cloth.press = { x: e.clientX, y: e.clientY, r: 64 * L.s, k: 1 }; S.lastInput = performance.now(); wake(); }
if (pr) {
const dx = x - pr.x, dy = y - pr.y, d2 = dx * dx + dy * dy, R2 = pr.r * pr.r;
if (d2 < R2) {
const w = (1 - d2 / R2) * (1 - d2 / R2), target = this.zPress[k] - 16 * pr.k * w;
if (pz > target) pz += (target - pz) * 0.18 * w;
}
}
The particles go to the GPU as a float texture. The vertex shader rebuilds a smooth surface from them with Catmull-Rom patches and their analytic tangents, so the folds are round, not faceted.
vec2 g = aUV * vec2(uN - 1);
ivec2 b = min(ivec2(floor(g)), uN - 2); vec2 f = g - vec2(b);
vec4 wx, dwx, wy, dwy; cr(f.x, wx, dwx); cr(f.y, wy, dwy);
vec4 P = vec4(0.0), Pu = vec4(0.0), Pv = vec4(0.0);
for (int j = 0; j < 4; j++) {
vec4 r = vec4(0.0), ru = vec4(0.0);
for (int i = 0; i < 4; i++) { vec4 s = tx(b.x - 1 + i, b.y - 1 + j); r += s * wx[i]; ru += s * dwx[i]; }
P += r * wy[j]; Pu += ru * wy[j]; Pv += r * dwy[j];
}
Silk shines along its threads. Kajiya-Kay highlights on the weft give the long bright streaks down every fold: a sharp white one, a broad gold one, a cool one from the fill light and a faint warp shot across.
// silk: Kajiya-Kay lobes on the weft (across the folds), a sharp white one and a broad gold one, + the warp shot
vec3 H1 = normalize(L1 + V), H2 = normalize(L2 + V);
float t1 = dot(Tu, H1), a1 = sqrt(max(1.0 - t1 * t1, 0.0));
float t2 = dot(Tu, H2), a2 = sqrt(max(1.0 - t2 * t2, 0.0));
float tv = dot(Tv, H1), av = sqrt(max(1.0 - tv * tv, 0.0));
float lit1 = smoothstep(-0.15, 0.45, d1);
col += vec3(1.0, 0.965, 0.9) * pow(a1, 90.0) * 1.2 * lit1;
col += vec3(1.0, 0.97, 0.92) * pow(a1, 600.0) * 0.5 * lit1;
col += base * vec3(1.0, 0.86, 0.66) * pow(a1, 10.0) * 0.85 * lit1;
col += vec3(0.72, 0.8, 1.0) * pow(a2, 90.0) * 0.32 * smoothstep(-0.1, 0.4, d2);
col += base * vec3(0.95, 0.82, 0.66) * pow(av, 40.0) * 0.18 * lit1;
Each glyph then asks the cloth where its own little patch went: the patch's position, its stretch and rotation, whether it faces away, and how much sheen washes over it. That becomes one CSS matrix per letter, so the text rides the folds and stays perfectly sharp.
function frameAt(cx, cy, hx, hy, flat) {
// screen affine of the cloth around rest point (cx, cy): hx, hy = half sample spans
const W = L.W, H = L.H;
if (flat) { s0[0] = L.cx - W / 2 + cx; s0[1] = L.railY + cy; M6[0] = 1; M6[1] = 0; M6[2] = 0; M6[3] = 1; M6[4] = 1; M6[5] = 1; return M6; }
cloth.at(cx / W, cy / H, p0); cloth.at((cx - hx) / W, cy / H, pl); cloth.at((cx + hx) / W, cy / H, pr);
cloth.at(cx / W, (cy - hy) / H, pu); cloth.at(cx / W, (cy + hy) / H, pd);
proj(p0, s0); proj(pl, sl); proj(pr, sr); proj(pu, su); proj(pd, sd);
const a = (sr[0] - sl[0]) / (2 * hx), b = (sr[1] - sl[1]) / (2 * hx), c = (sd[0] - su[0]) / (2 * hy), d = (sd[1] - su[1]) / (2 * hy);
// sheen at this point (the key light's sharp weft lobe) and facing
const ux = pr[0] - pl[0], uy = pr[1] - pl[1], uz = pr[2] - pl[2], vx = pd[0] - pu[0], vy = pd[1] - pu[1], vz = pd[2] - pu[2];
const nz = ux * vy - uy * vx;
const ul = Math.hypot(ux, uy, uz) || 1, t = (ux * HX + uy * HY + uz * HZ) / ul, an = Math.sqrt(Math.max(0, 1 - t * t));
const sheen = Math.pow(an, 90) + 0.3 * Math.pow(an, 10);
const det = a * d - b * c, vis = nz <= 0 ? 0 : Math.min(1, Math.max(0, (det - 0.03) / 0.12));
M6[0] = a; M6[1] = b; M6[2] = c; M6[3] = d; M6[4] = vis; M6[5] = 1 - Math.min(0.38, sheen * 0.45);
return M6;
}
When nothing moves any more, the loop stops drawing. Without WebGL2 the stage 1 swatch stays.
// sleep once everything is still
if (S.phase === 'open' && cloth.wind === 0 && !cloth.rip.length && !cloth.brush && !press && cloth.maxV < 0.02) S.restN++; else S.restN = 0;
if (S.restN > 40) { S.running = false; S.sleeping = true; return; }
function startFX() {
if (root.classList.contains('no-fx')) return false;
try { R = new SilkRenderer(canvas, cloth); } catch (e) { R = null; root.classList.add('no-fx'); return false; }
S.fx = true; S.lost = false;
return true;
}

3 · The finish
The chosen collection is remembered under one versioned key, and older keys of the component are wiped before the
first paint, where ?nofx can also ask for the swatch.
// storage: only dv72.v1.* survives; older dv72 keys are wiped before first paint. The theme is the page's own.
try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv72') && !k.startsWith('dv72.v1.')) localStorage.removeItem(k); } } catch (e) {}
try { const i = ITEMS.findIndex((x) => x.slug === localStorage.getItem(KEY)); if (i >= 0 && i !== DISABLED) current = i; } catch (e) { /* storage blocked */ }
A lost WebGL context drops to the swatch instead of an empty space, and a small test hook reports the state.
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); S.lost = true; S.fx = false; cancelAnimationFrame(raf); raf = 0; S.running = false;
root.classList.add('no-fx'); if (S.phase !== 'closed') placeInk(true);
});
window.__silk = {
ready, cloth,
get busy() { return S.running; },


Make it yours
- Your menu is the
ITEMSlist inapp.js: a number, a name and a slug per row. The links, the print and the rows follow it. - Your cloth is in
cloth.js:gathersets how full the pleats are,ghow heavy the silk falls, and the breeze is two sine waves you can calm or stir. - Your silk is the print texture in
print.jsand the lights inrender.js: a black silk is a darker ground and a cooler key light. - Accessibility is already there: a disclosure button with
aria-expandedandaria-controls, real links witharia-current, the full keyboard, a visible focus ring, a live status line, 44 px targets on touch,prefers-reduced-motion(the silk appears already hung, opening and closing are fades) and the CSS swatch without WebGL2.
Maison Élan is made up. Everything on the page was made in code for this project: the cloth is simulated in JavaScript, the silk is rendered by the shader, the fallback is CSS.
Keep the words in the DOM and let the material carry them. Each letter only needs to know where its own patch of cloth went, and a hover or a press becomes something you can feel in the fabric.