Library / Tutorial

Silk menu,
step by step.

A dropdown menu made of silk. The Collections menu pours out of the nav line as a verlet cloth, drapes, ripples from the row under your pointer, dents when you press and is drawn back up when you choose. Every word is a real link and a DOM glyph riding the cloth; the silk is WebGL2. Three stages, each a live page you can run.

3Stages
825Particles
0Libraries

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.

index.html — the button and the list
<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.

app.js — one span per glyph
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;
app.js — roman and italic
// 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; };
silk.css — the italic copy starts hidden
.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.

silk.css — the swatch
.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); }
app.js — the print, laid flat
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.

app.js — the keys
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(); }
});
silk.css — 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.

app.js — choose
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);
}
Stage 1 · the menuOpen ↗
The Maison Élan page: the nav with Collections open, a frosted ivory swatch under it with the six collections printed flat, Bridal hovered in italic with a red number, Evening current with a red dot, the hero line on the left.

2 · The silk

One transparent canvas goes under the print. It covers only the silk's box, not the page.

index.html — the canvas
<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.

cloth.js — the links
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.

cloth.js — pleats from the gather
// 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.

cloth.js — a step
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.

cloth.js — folds that stay soft
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; }
cloth.js — silk does not stretch
// 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.

cloth.js — folded behind the nav
// 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));
}
app.js — the pour
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.

app.js — drawn back up
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.

app.js — a hover starts a ripple
if (!reduced && S.fx && S.phase !== 'closing') { cloth.ripple(rowV(i), 1); S.lastInput = performance.now(); wake(); }
cloth.js — the ripple fronts
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];
}
app.js — a press
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(); }
cloth.js — the dent
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.

render.js — a smooth surface from the grid
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.

render.js — the sheen
// 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.

app.js — the cloth under a glyph
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.

app.js — sleep at rest
// 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; }
app.js — WebGL2, or the swatch
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;
}
Stage 2 · the silkOpen ↗
The same page with the WebGL2 silk: champagne silk hanging in soft pleats from the nav line under Collections, long highlights down the folds, the six collections printed on it and bending with the cloth, Bridal hovered with a ripple running through the silk.

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.

index.html — wipe old keys
// 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) {}
app.js — read the choice
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.

app.js — a lost context
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);
});
app.js — the test hook
window.__silk = {
  ready, cloth,
  get busy() { return S.running; },
Stage 3 · the finished menuOpen ↗
The finished silk menu: the champagne silk hanging from the nav with the six collections printed on it, Bridal hovered in italic and the ripple running through the folds.
Stage 3 · on a phoneOpen ↗
The silk menu on a phone: MAISON ÉLAN on two lines, Collections and the bag in the bar, the champagne silk hanging under it with the six collections printed on it, Evening current in italic with a red dot, and the hero line Cut from one bolt of silk. below.

Make it yours

  • Your menu is the ITEMS list in app.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: gather sets how full the pleats are, g how heavy the silk falls, and the breeze is two sine waves you can calm or stir.
  • Your silk is the print texture in print.js and the lights in render.js: a black silk is a darker ground and a cooler key light.
  • Accessibility is already there: a disclosure button with aria-expanded and aria-controls, real links with aria-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.

The one idea worth keeping

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.