Library / Tutorial

Morph nav,
step by step.

A mega-navigation that is one piece of glass. A floating capsule holds the menu; point at a section and the capsule itself pours down into its panel, and moving across the menu reshapes that same drop on four springs while the content slides through it. A lens follows the pointer, the light on the glass follows too, and the whole nav re-flows by the width of its container: a desktop capsule, a tablet capsule, a phone sheet with an accordion. Six stages, each a live page you can use.

6Stages
3Looks
0Libraries

This is the whole build of Morph nav, in the order it was written. Each stage below is a live page: point at the menu, move across it, use the arrow keys, and drag the handle on the demo window's right edge to make it narrower. The code under each heading is only what that stage adds; the finished source is at the end.

Three files do the work from the start: index.html for the page and the nav, style.css for the tokens and the layouts, and app.js for the behaviour. From stage 3 there is a fourth, spring.js. The site behind the nav is a demo: every link on it, and in the nav, goes to #, so nothing opens.

1 · The markup, the tokens and three looks

The first stage is the whole page in a 432 × 768 frame that app.js scales to fit the screen: the title, and under it a small browser window with a demo site inside. On top of that site floats the component, a <header class="mn">. Its bar holds the brand, a <nav> with four sections, a call to action and a burger (hidden until the phone layout). Each section is a real <button> that says what it controls with aria-expanded and aria-controls, followed by its panel, a role="region" labelled by that button.

index.html — the capsule, and the first section's trigger
<header class="mn" id="mn">
  <div class="bar">
    <a class="brand" href="#" aria-label="Strata, home (demo: goes nowhere)">
      <svg viewBox="0 0 32 32" aria-hidden="true"><path class="b1" d="M4 22 16 6l12 16z"/><path class="b2" d="M9 26h14"/></svg><b>Strata</b>
    </a>

    <nav class="nav" aria-label="Main">
      <div class="sheet" id="mn-sheet">
        <div class="sheet-in">
          <ul class="tops">

            <li class="it" data-sec="product">
              <button class="tg" id="tg-product" type="button" aria-expanded="false" aria-controls="sec-product"><span>Product</span><svg class="chev" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>

Inside the panel, .sec-in is a grid of blocks: columns of links, a featured card, a code card, a "See all" link. Later each of these blocks slides on its own, so they are direct children of the grid.

index.html — the first section's panel opens like this
<div class="sec" id="sec-product" role="region" aria-labelledby="tg-product">
  <div class="sec-in s-product">
    <div class="col">
      <p class="col-h">Platform</p>
      <a class="lk" href="#"><span class="ico"><svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg></span><span class="lt"><b>Analytics</b><small>Every metric, live</small></span></a>
      <a class="lk" href="#"><span class="ico"><svg viewBox="0 0 24 24"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg></span><span class="lt"><b>Automations</b><small>Workflows on autopilot</small></span></a>
      <a class="lk" href="#"><span class="ico"><svg viewBox="0 0 24 24"><path d="M9 3v5M15 3v5M6 8h12v4a6 6 0 0 1-12 0zM12 18v4"/></svg></span><span class="lt"><b>Integrations</b><small>Connect 120+ tools</small></span></a>
    </div>

Every colour, shadow and material comes from a CSS variable, in three layers. At the bottom are the primitives: neutral ramps with no meaning. The whole component is black and white; colour is light and material, never hue.

style.css — the primitives
:root {
  --n-1000: #000; --n-975: #050506; --n-950: #08080A; --n-900: #0E0E11; --n-850: #151519; --n-800: #1D1D22;
  --n-700: #2B2B31; --n-500: #6E6E78; --n-400: #A0A0A9; --n-300: #C6C6CD; --n-200: #E3E3E8; --n-100: #F2F2F4; --n-0: #fff;
  --w-4: rgb(255 255 255 / .04); --w-6: rgb(255 255 255 / .06); --w-9: rgb(255 255 255 / .09); --w-12: rgb(255 255 255 / .12);
  --w-18: rgb(255 255 255 / .18); --w-28: rgb(255 255 255 / .28); --w-40: rgb(255 255 255 / .4); --w-70: rgb(255 255 255 / .7);
  --b-6: rgb(0 0 0 / .06); --b-12: rgb(0 0 0 / .12); --b-25: rgb(0 0 0 / .25); --b-40: rgb(0 0 0 / .4); --b-70: rgb(0 0 0 / .7);
  --ch-50: #FBFBFC; --ch-100: #EEEFF2; --ch-200: #DCDDE2; --ch-300: #C6C8CE; --ch-400: #A4A7AF; --ch-500: #80838B;
  --ch-600: #5E6168; --ch-700: #3E4046; --ch-800: #26272B;
  --io-850: #1C1C1E; --io-800: #2C2C2E; --io-700: #3A3A3C; --io-500: #8E8E93; --io-400: #AEAEB2;
  --pl-50: #FAFAFA; --pl-100: #F2F2F4; --pl-200: #E5E5EA; --pl-300: #D1D1D6; --pl-700: #55555C;

  --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
  --serif: 'Instrument Serif', 'Times New Roman', serif;
  --mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

On top are the semantic tokens: what a value is for. The page, the demo window, the glass, the lens, the bar's type and the panel's type each get their own names, and a whole set is written once per look on [data-look]. Three looks ship from the first stage: glass (smoked liquid glass at night), metal (a brushed chrome capsule) and island (a solid black island on a paper-white page). Here is glass.

style.css — the semantic tokens of the glass look
/* A · GLASS: smoked liquid glass at night. A clear lens with a bright rim, white type, white light behind */
[data-look="glass"] {
  --page: var(--n-975);
  --field-bg: radial-gradient(ellipse 70% 42% at 50% 60%, var(--n-850) 0%, var(--n-950) 62%, var(--n-975) 100%);
  --on-field: var(--n-0); --on-field-muted: var(--n-400); --chip: var(--n-0); --on-chip: var(--n-1000); --em: var(--n-300); --avatar-ring: var(--n-300); --lockup: none;
  --win: var(--w-6); --win-edge: var(--w-12); --win-shadow: 0 50px 90px -30px var(--n-1000); --on-win: var(--n-400); --win-dot: var(--w-18);
  --site: var(--n-950); --on-site: var(--n-0); --on-site-muted: var(--n-400); --on-site-soft: var(--n-300); --site-line: var(--w-12); --site-card: var(--w-4); --site-dots: var(--w-9); --site-btn: var(--n-0); --on-site-btn: var(--n-1000);
  --light: rgb(255 255 255 / .16); --beam: rgb(255 255 255 / .07); --vscrim: rgb(0 0 0 / .5);
  --glass: linear-gradient(180deg, var(--w-9), var(--w-4) 60%), rgb(14 14 17 / .56); --glass-filter: blur(28px) saturate(1.5) brightness(1.08);
  --glass-rim: inset 0 1px 0 var(--w-40), inset 0 -1px 0 var(--w-6), inset 0 0 0 1px var(--w-12), inset 0 0 30px var(--w-4);
  --glass-drop: 0 40px 80px -28px var(--n-1000), 0 12px 24px -16px var(--b-70);
  --pane-r: 28px; --spot: var(--w-9); --rim: var(--w-70); --sheen: var(--w-12);
  --lens-bg: radial-gradient(closest-side, transparent 56%, var(--w-6) 80%, var(--w-18) 100%), linear-gradient(180deg, var(--w-12), transparent 50%);
  --lens-filter: brightness(1.3) saturate(1.2);
  --lens-rim: inset 0 0 0 1px var(--w-28), inset 0 1.5px 3px var(--w-40), inset 0 -2px 6px var(--w-6), 0 10px 22px -12px var(--n-1000);
  --on-lens: var(--n-0); --on-lens-muted: var(--n-300);
  --on-bar: var(--n-0); --on-bar-muted: var(--n-400); --focus: var(--n-0);
  --cta: var(--n-0); --on-cta: var(--n-1000); --cta-glow: 0 6px 20px -8px var(--w-40); --burger: var(--w-6);
  --on-pane: var(--n-0); --on-pane-muted: var(--n-400); --pane-head: var(--n-400); --pane-rule: var(--w-9);
  --ico: linear-gradient(180deg, var(--w-12), var(--w-4)); --ico-edge: var(--w-12); --on-ico: var(--n-0);
  --feat: linear-gradient(160deg, var(--n-0), var(--n-200)); --on-feat: var(--n-1000); --on-feat-muted: var(--n-700); --chart: linear-gradient(180deg, var(--n-1000), var(--n-700));
  --code: rgb(0 0 0 / .45); --on-code: var(--n-100); --on-code-muted: var(--n-400);
  --badge: var(--n-0); --on-badge: var(--n-1000);
  --foot-fade: rgb(14 14 17 / .9); --foot-sign: var(--n-850); --grip: var(--w-28);
  --word: var(--n-0); --word-glow: var(--w-18);
  --bloom: var(--w-40); --ripple: var(--w-70);
  --pill: var(--w-6); --pill-edge: var(--w-12); --on-pill: var(--n-0); --on-pill-muted: var(--n-400); --pill-icon: var(--n-0); --on-pill-icon: var(--n-1000);
}

Each component then names the tokens it uses, and its rules read only those names. Changing a look never touches a rule.

style.css — the nav's component tokens
.mn       { --mn-ink: var(--on-bar); --mn-muted: var(--on-bar-muted); --mn-focus: var(--focus); --mn-lit: var(--on-lens); --mn-lit-dim: var(--on-lens-muted);
            --mn-cta: var(--cta); --mn-on-cta: var(--on-cta); --mn-cta-glow: var(--cta-glow); --mn-burger: var(--burger);
            --pn-bg: var(--glass); --pn-filter: var(--glass-filter); --pn-rim: var(--glass-rim); --pn-drop: var(--glass-drop); --pn-spot: var(--spot); --pn-light: var(--rim); --pn-sheen: var(--sheen);
            --pn-ink: var(--on-pane); --pn-dim: var(--on-pane-muted); --pn-h: var(--pane-head); --pn-hr: var(--pane-rule);
            --pn-ico: var(--ico); --pn-ico-edge: var(--ico-edge); --pn-on-ico: var(--on-ico);
            --pn-feat: var(--feat); --pn-on-feat: var(--on-feat); --pn-on-feat-dim: var(--on-feat-muted); --pn-chart: var(--chart);
            --pn-code: var(--code); --pn-on-code: var(--on-code); --pn-on-code-dim: var(--on-code-muted);
            --pn-badge: var(--badge); --pn-on-badge: var(--on-badge); --pn-foot-fade: var(--foot-fade); --pn-foot-sign: var(--foot-sign); --pn-grip: var(--grip); }

A script in the page's head picks the look from ?look=glass, metal or island before the first paint, so the page never flashes the wrong one.

index.html — the look, before the first paint
(() => {
  const html = document.documentElement, q = new URLSearchParams(location.search);
  const look = { a: 'glass', b: 'metal', c: 'island' }[q.get('look')] || q.get('look');
  html.dataset.look = ['glass', 'metal', 'island'].includes(look) ? look : 'glass';
  if (q.get('full') === '1') html.dataset.full = '';
})();

The demo page inside the window is 880 CSS px wide and drawn 400 px wide. That matters: it is a container (container: site / inline-size), and the nav's layout is written as container queries on it, not as media queries on the screen. At 880 px only the desktop block applies: the capsule shrinks to its content and floats in the middle, and each section's grid gets its columns.

style.css — stage 1: the window is fixed
/* stage 1: the window is fixed, 880 CSS px drawn 400 px wide (from stage 2 app.js sizes it) */
.vp { height: 766px; transform: scale(.45455); }
style.css — the desktop layout, a container query
/* ---- 9. layout · DESKTOP (container ≥ 860 px): the capsule floats in the middle; the drop flows down under it ---- */
@container site (min-width: 860px) {
  .vp { --cap-h: 58px; }
  .bar { width: max-content; margin: 14px auto 0; padding: 0 7px 0 20px; }
  .brand { padding-right: 18px; }
  .s-product { grid-template-columns: 276px 262px 196px; }
  .s-solutions { grid-template-columns: 318px 172px; }
  .s-developers { grid-template-columns: 262px 292px; }
  .s-company { grid-template-columns: 280px; }
  .feat { min-height: 100%; }
}

For now the capsule is drawn on the bar itself, and each section is a dropdown of its own, centred under the capsule and shown by CSS on hover and on focus. A short delay on hiding bridges the gap between the trigger and its panel. This is the classic mega-menu; the rest of the tutorial replaces it.

style.css — stage 1 only: the capsule and the dropdowns
/* ---- stage 1 only: the capsule is drawn on the bar itself, and each section is a dropdown of its own, centred
        under the capsule and shown by CSS on hover and on focus (a short delay on hiding bridges the gap).
        Stage 3 replaces both with ONE drop of glass that app.js shapes. ---- */
.bar { position: relative; }
.bar::before { content: ''; position: absolute; inset: 0; z-index: 0; border-radius: 999px; pointer-events: none;
  background: var(--pn-bg); box-shadow: var(--pn-rim), var(--pn-drop); -webkit-backdrop-filter: var(--pn-filter); backdrop-filter: var(--pn-filter); }
.sec { left: 50%; top: 100%; padding-top: 10px; translate: -50% 0; transition: visibility 0s .15s; }
.sec-in { border-radius: var(--pane-r); background: var(--pn-bg); box-shadow: var(--pn-rim), var(--pn-drop); -webkit-backdrop-filter: var(--pn-filter); backdrop-filter: var(--pn-filter); }
.it:hover .sec, .it:focus-within .sec { visibility: visible; transition-delay: 0s; }
app.js — all of it: fit the frame, colour the browser
// fit the 432 × 768 composition to the screen
function fit() { html.style.setProperty('--k', Math.min(innerWidth / 432, innerHeight / 768).toFixed(4)); }
fit();
addEventListener('resize', fit);
document.querySelector('meta[name="theme-color"]').content = getComputedStyle(html).getPropertyValue('--page').trim();
Stage 1 · point at a section: a plain CSS dropdownOpen ↗

2 · The layout follows the container

Now the window can be resized. Its page always renders at a real CSS width W (360 to 1040 px), and app.js scales it to fit the frame: a narrow window is drawn phone-shaped, a wide one desktop-shaped. Because the width is real, the container queries really fire, exactly as they would in a browser window of that size.

app.js — the demo window
// the demo window: W is its real CSS width. On the frame it is drawn between 220 and 400 px wide, so a narrow window
// is drawn phone-shaped (tall), a wide one desktop-shaped.
// ================================================================================================================
const W_MIN = 360, W_MAX = 1040, FW_MIN = 220, FW_MAX = 400, CLIP_H = 348;
let W = 0;
function sizeWindow(w) {
  w = Math.round(clamp(w, W_MIN, W_MAX));
  if (w === W) return;
  W = w;
  const fw = FW_MIN + (FW_MAX - FW_MIN) * (W - W_MIN) / (W_MAX - W_MIN), k = fw / W;
  device.style.width = px(fw);
  device.style.left = px((432 - fw) / 2);
  vp.style.width = `${W}px`;
  vp.style.height = px(CLIP_H / k);
  vp.style.transform = `scale(${k.toFixed(5)})`;
}
sizeWindow(+params.get('w') || 880);

The page in the window is the container the queries ask about. It is scaled with a transform, which changes how big it is drawn but not its CSS width, so the queries see the real W.

style.css — the demo page is the container
.vp { position: absolute; left: 0; top: 0; width: 880px; height: 800px; transform-origin: 0 0; container: site / inline-size; color: var(--st-ink); overflow: hidden;
  background: radial-gradient(circle at 1px 1px, var(--site-dots) 1px, transparent 1.4px) 0 0 / 22px 22px, var(--site); }

The stylesheet decides the layout, and only the stylesheet. The base styles are the phone layout, and each container query writes the layout's name into a custom property, --mode: compact under 600 px, bar from 600 to 859 px, full from 860 px.

style.css — the base: compact
.mn { position: absolute; left: 0; right: 0; top: 0; z-index: 10; color: var(--mn-ink); --mode: compact; }
style.css — tablet: the capsule spans the top
/* ---- 10. layout · TABLET (600–859 px): the capsule spans the top; the drop flows into a 2-column sheet ---- */
@container site (min-width: 600px) and (max-width: 859px) {
  .mn { --mode: bar; }
style.css — mobile: a burger in the capsule
/* ---- 11. layout · MOBILE (< 600 px): the capsule spans the top; it swells into a glass sheet with an accordion ---- */
@container site (max-width: 599px) {
  .cta { height: 40px; padding: 0 16px; font-size: 14px; margin-left: auto; }
  .cta svg { display: none; }
  .burger { display: block; }

The script reads --mode back after every resize. It never repeats a breakpoint: change a number in the CSS and the behaviour follows. The read-out above the window shows the mode and the width, and the handle is a real role="slider" that also answers the arrow keys, Page Up/Down, Home and End.

app.js — the mode comes from CSS
// The stylesheet decides the layout with container queries and writes it into --mode (full | bar | compact).
const MODE_NAME = { full: 'Desktop', bar: 'Tablet', compact: 'Mobile' };
let mode = '';

function onResize() {
  mode = getComputedStyle(mn).getPropertyValue('--mode').trim() || 'full';
  if (mode === 'bar') mn.style.setProperty('--sheet-w', `${W - 24}px`);   // the tablet panel spans the window
  readout();
}
Stage 2 · drag the handle: desktop, tablet, phoneOpen ↗

3 · One drop, on springs

Here the dropdowns go. The capsule's glass becomes its own element, .glass, behind the bar: one box with the material of the look. Closed, it is exactly the capsule. Open, it pours down from the capsule into the panel of the section you point at. Move to the next section and the same piece of glass reshapes: there is never a second panel and never a gap between bar and panel.

style.css — the drop
.glass { position: absolute; left: 0; top: 0; width: 10px; height: 10px; overflow: hidden; border-radius: var(--pane-r); z-index: 1; pointer-events: none;
  background: var(--pn-bg); box-shadow: var(--pn-rim), var(--pn-drop); -webkit-backdrop-filter: var(--pn-filter); backdrop-filter: var(--pn-filter); }

From here on, spring.js drives the motion: a value with a target, a velocity, a stiffness and a damping, stepped in fixed slices so it moves the same at 60 and 120 frames a second. The drop is four of them, one per edge. When it reshapes, the edge that has the furthest to travel gets the bouncier spring and leads; the others follow on a calmer one. That is what makes it pour instead of scale.

app.js — the drop: four sprung edges
class Drop {
  constructor() { [this.l, this.r, this.t, this.b] = [0, 0, 0, 0].map(() => new Spring(0)); }
  to({ x, y, w, h }, lead = spring(300, 17), trail = spring(240, 24)) {
    const pair = (a, b, va, vb) => {
      const da = Math.abs(va - a.target), db = Math.abs(vb - b.target);
      a.set(da >= db ? lead : trail); b.set(db >= da ? lead : trail);
      a.target = va; b.target = vb;
    };
    pair(this.l, this.r, x, x + w);
    pair(this.t, this.b, y, y + h);
  }
  place(box) { this.to(box); this.springs().forEach(snap); }
  springs() { return [this.l, this.r, this.t, this.b]; }
  get box() { return { x: this.l.value, y: this.t.value, w: this.r.value - this.l.value, h: this.b.value - this.t.value }; }
}

Where the drop goes is one function. Closed, its target is the capsule's box. Open, it is the capsule widened to the section's natural width (or the window's, on a tablet), centred under the capsule but kept inside the window, and tall enough for the bar plus the section.

app.js — where the drop is heading
// where the drop is heading: the capsule, or the capsule poured down into the open section
function retarget() {
  let box = cap;
  if (mode !== 'compact' && active >= 0) {
    const s = secs[active], w = mode === 'full' ? Math.max(cap.w, s.w) : W - 24;
    box = { x: clamp(cap.x + cap.w / 2 - w / 2, 12, W - 12 - w), y: cap.y, w, h: cap.h + s.h };
  }
  opened.target = box === cap ? 0 : 1;
  glass.to(box);
}

Every frame the drop's box is written to .glass. The open section is laid out at its own size under its own trigger and clipped to the drop with clip-path, so while the glass is still pouring the content is cut by its moving edge, rounded like it.

app.js — draw the drop and clip the section to it
function renderGlass() {
  const { x, y, w, h } = glass.box;
  const r = Math.min(RADIUS, w / 2, Math.max(0, h) / 2);
  promote(glassEl, !glass.springs().every((s) => s.resting(0.05)));
  put(glassEl, 'transform', `translate(${px(x)}, ${px(y)})`);
  put(glassEl, 'width', px(Math.max(0, w))); put(glassEl, 'height', px(Math.max(0, h))); put(glassEl, 'borderRadius', px(Math.max(0, r)));
  put(rule, 'opacity', (clamp(opened.value, 0, 1) * clamp((h - cap.h) / 40, 0, 1)).toFixed(3));
  if (mode === 'compact') return;
  // the open section: under its own trigger, inside the drop, clipped to it
  const s = secs[active];
  if (!s) return;
  const t = boxOf(s.tg);
  s.x = clamp(t.x + t.w / 2 - s.w / 2, x, Math.max(x, x + w - s.w)); s.y = y + cap.h;
  const inset = [0, s.x + s.w - (x + w), s.y + s.h - (y + h), x - s.x].map((v) => px(Math.max(0, v)));
  put(s.el, 'transform', `translate(${px(s.x)}, ${px(s.y)})`);
  put(s.el, 'clipPath', `inset(${inset.join(' ')} round 0 0 ${px(r)} ${px(r)})`);
}

The nav is now a real disclosure menu. Only the open section is reachable: the others are inert, and each trigger's aria-expanded tells the truth.

app.js — show a section
// a section is shown (and reachable) only while it is the open one
function reveal(s, on) {
  s.el.classList.toggle('is-on', on);
  s.el.inert = !on;
  s.tg.setAttribute('aria-expanded', String(on));
}

Hover gets intent. The first open waits 90 ms, so a pointer that only crosses the nav opens nothing. Once a panel is open, moving to the next trigger switches at once, except when the pointer is heading down into the open panel: then it is inside a "safe triangle" between where it left its trigger and the panel's top edge, and the switch waits a moment, so a diagonal move does not flip the panel on the way.

app.js — hover intent and the safe triangle
// ---- hover intent: a short delay before the first open; a safe triangle while the pointer travels to the panel ----
const INTENT_OPEN = 0.09, INTENT_SWITCH = 0.14, INTENT_CLOSE = 0.28;     // seconds
let hoverTimer = 0, closeTimer = 0, over = -1, anchor = null;
const hovering = (e) => e.pointerType === 'mouse' || e.pointerType === 'pen';
const inTriangle = (p, a, b, c) => {
  const s = (p1, p2, p3) => (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
  const d1 = s(p, a, b), d2 = s(p, b, c), d3 = s(p, c, a);
  return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
};
secs.forEach((s) => {
  s.tg.addEventListener('pointerenter', (e) => {
    if (!hovering(e) || mode === 'compact') return;
    over = s.i;
    clearTimeout(hoverTimer); clearTimeout(closeTimer);
    if (active < 0) { hoverTimer = setTimeout(() => over === s.i && show(s.i), INTENT_OPEN * 1000); return; }
    if (s.i === active) return;
    // heading for the open panel across this trigger? wait a moment before switching
    const p = toPage(e), top = cap.y + cap.h, { x, w } = glass.box;
    const aiming = anchor && inTriangle(p, anchor, { x, y: top }, { x: x + w, y: top });
    if (aiming) hoverTimer = setTimeout(() => over === s.i && show(s.i), INTENT_SWITCH * 1000);
    else show(s.i);
  });

On the keyboard, the arrows walk the triggers (and switch the open panel), Home and End jump, Arrow Down enters the panel and walks its links, and Escape closes and gives focus back to the trigger. Focus leaving the nav, or a tap outside it, closes the panel too.

app.js — the triggers' keys
function onTriggerKey(e, s) {
  if (mode === 'compact') return;
  const n = secs.length, go = (i) => { secs[i].tg.focus(); if (active >= 0) show(i); };
  if (e.key === 'ArrowRight') go((s.i + 1) % n);
  else if (e.key === 'ArrowLeft') go((s.i - 1 + n) % n);
  else if (e.key === 'Home') go(0);
  else if (e.key === 'End') go(n - 1);
  else if (e.key === 'ArrowDown') { show(s.i); links(s)[0].focus(); }
  else if (e.key === 'Escape' && active >= 0) hide();
  else return;
  e.preventDefault();
}

The loop only runs while a spring is moving; when everything rests, it stops.

app.js — the loop
const springs = () => [...glass.springs(), opened];
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
  const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
  last = now;
  clock += dt;
  const all = springs();
  all.forEach((s) => s.step(dt));
  put(vscrim, 'opacity', clamp(opened.value, 0, 1).toFixed(3));
  renderGlass();
  if (!all.every((s) => s.resting(0.002))) requestAnimationFrame(frame); else running = false;
}
Stage 3 · move across the menu: one drop reshapesOpen ↗

4 · The morph: sliding content, lenses, light

The glass already moves; now its content moves with it. Every block of a section has its own spring. When you move to the right, the old section's blocks leave to the left, all together and fast, and the new ones arrive from the right, one after another, 45 ms apart. On a fresh open they rise instead.

app.js — enter and leave
// a section's blocks slide in from the side you moved towards (or rise, on a fresh open), one after another
function enter(s, dir, fresh) {
  s.el.classList.add('is-on'); s.el.classList.remove('is-out');
  s.el.inert = false;
  s.side = dir; s.rise = fresh ? 1 : 0;
  s.blocks.forEach((b, k) => {
    b.e.set(spring(320, 24));
    if (fresh) { b.e.value = 0; b.e.velocity = 0; }
    b.e.target = 0;
    later((fresh ? 0.1 : 0.02) + k * STAGGER, () => { b.e.target = 1; });
  });
}
app.js — each block, drawn from its spring
function renderBlocks(s) {
  for (const b of s.blocks) {
    const e = clamp(b.e.value, 0, 1.2), moving = !b.e.resting(0.004);
    promote(b.el, moving);
    put(b.el, 'opacity', clamp(b.e.value, 0, 1).toFixed(3));
    put(b.el, 'transform', moving ? `translate3d(${px(s.side * (1 - e) * SLIDE)}, ${px(s.rise * (1 - e) * RISE)}, 0)` : '');
  }
}

Two lenses follow the pointer: one across the triggers, one across the links of the open panel. A lens is a small drop. The edge in front gets the stiff spring and the edge behind the soft one, so it stretches while it travels; and it keeps its volume, so the longer it gets the thinner it is. The link lens stays on its link across the gaps, and only leaves when the pointer leaves the section.

app.js — a lens
// A lens: a small drop that follows the pointer. The edge in front leads and the one behind lags, so it stretches
// while it travels — and keeps its volume: the longer it gets, the thinner. Hidden, it fades and reappears in place.
class Lens extends Drop {
  constructor(el) { super(); this.el = el; this.o = new Spring(0, spring(320, 28)); this.rest = { w: 1, h: 1 }; }
  to({ x, y, w, h }) {
    const lead = spring(620, 30), trail = spring(210, 17);
    if (this.o.value < 0.02) this.place({ x, y, w, h });
    const dx = x - this.l.target, dy = y - this.t.target;
    if (Math.abs(dx) > 0.5) { (dx > 0 ? this.r : this.l).set(lead); (dx > 0 ? this.l : this.r).set(trail); }
    if (Math.abs(dy) > 0.5) { (dy > 0 ? this.b : this.t).set(lead); (dy > 0 ? this.t : this.b).set(trail); }
    this.l.target = x; this.r.target = x + w; this.t.target = y; this.b.target = y + h;
    this.rest = { w, h };
    this.o.target = 1;
  }
  place(box) { [[this.l, box.x], [this.r, box.x + box.w], [this.t, box.y], [this.b, box.y + box.h]].forEach(([s, v]) => { s.value = s.target = v; s.velocity = 0; }); }
  hide() { this.o.target = 0; }
  springs() { return [...super.springs(), this.o]; }
  render() {
    const { x, y, w, h } = this.box;
    const sw = w / Math.sqrt(Math.max(1, h / this.rest.h)), sh = h / Math.sqrt(Math.max(1, w / this.rest.w));   // keep the volume
    promote(this.el, !this.springs().every((s) => s.resting(0.01)));
    put(this.el, 'opacity', clamp(this.o.value, 0, 1).toFixed(3));
    put(this.el, 'transform', `translate(${px(x + (w - sw) / 2)}, ${px(y + (h - sh) / 2)})`);
    put(this.el, 'width', px(Math.max(0, sw))); put(this.el, 'height', px(Math.max(0, sh)));
  }
}
app.js — the menu lens: under the pointer, else on the open trigger
function aimMenuLens() {
  const i = over >= 0 ? over : active;
  secs.forEach((s) => s.tg.classList.toggle('is-lit', s.i === i));
  if (i < 0 || mode === 'compact') menuLens.hide();
  else { const t = boxOf(secs[i].tg); menuLens.to({ x: t.x, y: t.y + 2, w: t.w, h: t.h - 4 }); }
  wake();
}

Then the light. A spotlight and a rim light inside the glass trail the pointer (two more springs feed --mx and --my); the rim is a ring cut out with a mask, so only the edge of the glass lights up.

style.css — the spotlight, the rim light, the sheen, the hairline
.g-spot { inset: 0; background: radial-gradient(300px circle at var(--mx, 50%) var(--my, 0), var(--pn-spot), transparent 70%); }
.g-rim { inset: 0; border-radius: inherit; padding: 1px; background: radial-gradient(240px circle at var(--mx, 50%) var(--my, 0), var(--pn-light), transparent 70%);
  -webkit-mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }
.g-sheen { top: -20%; bottom: -20%; left: 0; width: 46%; opacity: 0; background: linear-gradient(100deg, transparent 0%, var(--pn-sheen) 50%, transparent 100%); }
.g-rule { left: 18px; right: 18px; top: var(--cap-h); height: 1px; opacity: 0; background: linear-gradient(90deg, transparent, var(--pn-hr) 15%, var(--pn-hr) 85%, transparent); }

When the section changes, a band of light sweeps across the drop the way you moved, and a hairline parts the bar from the panel while it is open.

app.js — the sheen
const sw = clamp(sheenAge / 0.65, 0, 1), from = sheenDir > 0 ? -0.5 : 1.05, to = sheenDir > 0 ? 1.05 : -0.5;
put(sheen, 'opacity', sheenAge < 0.65 ? Math.sin(Math.PI * sw).toFixed(3) : '0');
put(sheen, 'transform', `translateX(${px(w * (from + (to - from) * (1 - (1 - sw) ** 2)))}) skewX(-14deg)`);

Behind the glass, inside the demo page, three soft lights lean towards the open section, on a slow spring, so the glass has something to bend.

app.js — the light behind the glass
// ---- the light behind the glass: three soft glows that lean towards the open section ----
const lights = $$('.lights i');
const LIGHTS = [[0.22, 300, 0.9], [0.62, 200, -0.6], [0.9, 520, 0.5]];   // x (of W), y, how hard it leans
function renderLight() {
  lights.forEach((el, i) => {
    const [fx, y, k] = LIGHTS[i];
    put(el, 'transform', `translate(${px(W * fx + lean.value * W * 0.5 * k)}, ${px(y)})`);
  });
}
Stage 4 · move across the menu and the linksOpen ↗

5 · The phone: a sheet, a burger, an accordion

Under 600 px the capsule holds the brand, the call to action and the burger. Press the burger and the same drop swells out of the capsule into a full-height glass sheet. The menu lives inside it, positioned over the part of the drop under the bar; the same four sections become an accordion.

style.css — the sheet, placed by app.js
.sheet { position: absolute; left: 0; top: 0; z-index: 5; overflow: hidden; visibility: hidden; }   /* placed inside the drop by app.js, above its glass */
.sheet.is-on { visibility: visible; }
.sheet-in { position: absolute; inset: 0; display: flex; flex-direction: column; padding: 4px 12px 0; overflow-y: auto; overscroll-behavior: contain; }   /* a short phone: the menu scrolls */
app.js — the sheet is the part of the drop under the capsule
// the sheet: the part of the drop under the capsule
const { x, y, w } = glass.box, h = Math.max(0, glass.box.h + pull.value), top = cap.y + cap.h;
put(sheet, 'left', px(x)); put(sheet, 'top', px(top)); put(sheet, 'width', px(Math.max(0, w)));
put(sheet, 'height', px(Math.max(0, y + h - top)));
sheet.classList.toggle('is-on', y + h - top > 2);

Open, the sheet is a modal dialog: the page behind it is inert, the rows drop in one after another, and the burger turns into a cross.

app.js — open the sheet
function openSheet() {
  if (sheetOpen) return;
  sheetOpen = true;
  burger.setAttribute('aria-expanded', 'true'); burger.setAttribute('aria-label', 'Close menu');
  outside.forEach((el) => { el.inert = true; });
  mn.classList.add('is-sheet');
  cross.target = 1;
  retarget();
  secs.forEach((s, k) => { s.pop.value = 0; s.pop.target = 0; later(0.08 + k * 0.05, () => { s.pop.target = 1; }); });
  later(0.06, () => { if (sheetOpen) secs[0].tg.focus({ preventScroll: true }); });   // once the sheet is visible
  wake();
}

One section opens at a time; its height springs to its content's.

app.js — the accordion
// the accordion: one section open at a time; its height springs to the content's
function toggleAccordion(i) {
  const next = acc === i ? -1 : i;
  if (acc >= 0) { secs[acc].acc.target = 0; secs[acc].el.inert = true; secs[acc].tg.setAttribute('aria-expanded', 'false'); }
  acc = next;
  if (acc >= 0) {
    const s = secs[acc];
    s.acc.target = s.h; s.el.inert = false; s.tg.setAttribute('aria-expanded', 'true');
    s.side = 0; s.rise = 1;
    s.blocks.forEach((b, k) => { b.e.value = 0; b.e.target = 0; later(0.05 + k * STAGGER, () => { b.e.target = 1; }); });
  }
  wake();
}

Tab is trapped between the burger and the sheet, and Escape closes it and returns focus to the burger.

app.js — the focus trap
// the trap: Tab cycles through the burger and the sheet; Escape closes and returns to the burger
addEventListener('keydown', (e) => {
  if (!sheetOpen) return;
  if (e.key === 'Escape') { closeSheet(); return; }
  if (e.key !== 'Tab') return;
  const stops = [burger, ...$$('button, a', sheet).filter((el) => el.offsetParent && !el.closest('[inert]'))];
  const i = stops.indexOf(document.activeElement), n = stops.length;
  e.preventDefault();
  stops[(i + (e.shiftKey ? -1 : 1) + n) % n].focus();
});

The sheet came down out of the capsule, so it goes back up: drag it up and let go (or flick it) to put it away. A drag that moved is not also a tap.

app.js — swipe up to close
addEventListener('pointerup', () => {
  if (!drag) return;
  const flick = drag.moved && (pull.value < -90 || drag.v < -0.6);
  if (drag.moved) sheet.addEventListener('click', (e) => e.stopPropagation(), { capture: true, once: true });
  drag = null;
  if (flick) closeSheet(); else pull.target = 0;
});
Stage 5 · press the burger, open a section, swipe upOpen ↗

6 · The finish

The last stage adds what makes it a finished piece. First the re-flow: when the layout changes, the pieces that stay (the brand, the triggers, the call to action, the burger) glide from where they were drawn to their new places (FLIP), the pieces that are new pop in, and the drop simply pours into the new capsule. The window's width is now a spring too, so a drag is smooth.

app.js — the window's width, on a spring
const win = new Spring(clamp(+params.get('w') || 880, W_MIN, W_MAX), critical(420));
app.js — the re-flow
function changeMode(from, to) {
  mode = to;
  if (!from) return;
  if (to === 'compact' && active >= 0) hide();
  if (to !== 'compact') { closeSheet({ instant: true }); if (acc >= 0) toggleAccordion(acc); }
  let k = 0;
  for (const f of flips) {
    if (!shown(f.el) || calm) continue;
    const now = boxOf(f.el), was = f.was;
    if (was && Math.abs(was.w / now.w - was.h / now.h) < 0.25) {   // the same shape: start where it was drawn, spring home
      f.x.value += was.x - now.x; f.y.value += was.y - now.y; f.k.value *= was.h / now.h;
    } else {                                                      // new here (or a different shape): pop in
      f.pop.value = 0; f.pop.target = 0;
      later(0.03 + k++ * 0.04, () => { f.pop.target = 1; });
    }
  }
  modeWord = MODE_NAME[to];
  syncWord(ORDER.indexOf(to) > ORDER.indexOf(from) ? 1 : -1);
  say(`${MODE_NAME[to]} layout`);
}

The first pour has an impact: a bloom of light and two ripples out of the trigger, and a small bump of the whole frame.

app.js — the impact
function impact() {
  if (calm || mode === 'compact' || active < 0) return;
  const t = boxOf(secs[active].tg);
  impAge = 0; impX = t.x + t.w / 2; impY = cap.y + cap.h;
  bump.velocity += 4;
}

A giant word above the window names what the nav is showing: the open section, the layout you resized into, or "Menu" on a phone. It rolls the way you moved, blurring in transit.

app.js — which word
function wordWanted() {
  if (mode === 'compact') return acc >= 0 ? secs[acc].name : sheetOpen ? 'Menu' : modeWord || 'Explore';
  return active >= 0 ? secs[active].name : modeWord || 'Explore';
}

Each look gets its own feel: glass is a light, clear liquid; metal is dense and wobbly; the island is a solid object that is quick, does not wobble, and reshapes in two strokes, sideways and then down. Its content focuses in out of a blur.

app.js — the physics per look
// each look's physics: the drop's leading and trailing edges, the lens's, how much the drop softens at speed, and
// whether content focuses in out of a blur, and whether the drop reshapes in two strokes (sideways, then down)
const FEEL = {
  glass: { drop: [[300, 17], [240, 24]], lens: [[620, 30], [210, 17]], soft: 0.006, focus: false, staged: false },  // a light, clear liquid
  metal: { drop: [[210, 10], [170, 13]], lens: [[460, 17], [150, 9]], soft: 0.012, focus: false, staged: false },    // dense mercury: slower, wobblier
  island: { drop: [[520, 34], [480, 33]], lens: [[760, 44], [620, 41]], soft: 0, focus: true, staged: true },       // a solid object: quick, in two strokes
}[LOOK];
app.js — the island's two strokes
if (FEEL.staged && !calm && Math.abs(box.h - was.h) > 30) {           // island: one stroke at a time, like a solid thing
  const growing = box.h > was.h, first = growing ? { ...box, y: was.y, h: was.h } : { x: glass.l.target, w: glass.r.target - glass.l.target, y: box.y, h: box.h };
  glass.to(first);
  const at = ++stroke;
  later(0.09, () => { if (at === stroke) glass.to(box); });
} else { stroke++; glass.to(box); }

The soft light in the room and behind the glass now drifts on one clock, studio.now(), so a capture tool can pause it or drive it frame by frame. With prefers-reduced-motion every spring is critically damped (things arrive without bouncing), the sheen, the bloom and the morph are gone, and the loop only runs while something moves.

app.js — the studio clock
// the studio clock: ambient life reads time from here, so a capture tool can pause or drive it
const studio = (window.__studio = window.__studio || {});
if (!studio.now) studio.now = () => performance.now();
app.js — reduced motion
// With reduced motion every spring is critically damped: things still arrive, they just don't bounce.
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
const critical = (stiffness) => ({ stiffness, damping: 2 * Math.sqrt(stiffness) });
style.css — reduced motion
/* ---- 16. reduced motion: no morph, no sheen, no bloom; things fade (app.js snaps the geometry) ---- */
@media (prefers-reduced-motion: reduce) {
  .fx, .p-sheen { display: none; }
  .tg .chev, .lk::after, .ico, .art-chart i { transition: none; }
}
Stage 6 · the finished navOpen ↗

Three looks, one token system

The three looks share the markup, the script and the three token layers. Each one is a block of semantic tokens on [data-look], a few shapes it draws differently, and one row of physics in FEEL. The header of style.css is the map.

style.css — its header: the sections
/* Morph nav — a liquid mega-navigation in black and white. A floating capsule flows down into ONE panel that
   reshapes between the sections, a lens follows the pointer across the menu and the links, and the whole nav
   re-flows by its container's width: desktop capsule → tablet capsule → mobile burger + sheet.
   Three looks (?look=glass|metal|island): smoked liquid glass, liquid metal, a solid black island.

   1 tokens: primitive → semantic (per look) → component     2 stage, frame, ambient     3 the frame's words
   4 the demo window and its handle     5 the demo site and its light     6 the nav: a floating capsule     7 the drop
   8 the lenses     9 layout · desktop (full)     10 layout · tablet (bar)     11 layout · mobile (compact)
   12 the impact     13 the frame's footer     14 the looks' own touches     15 ?full=1     16 reduced motion

Here is metal's semantic set: the glass becomes brushed chrome (a fine line pattern over a vertical gradient of greys), the lens becomes a black mirror, and the type turns black.

style.css — the metal set of semantic tokens
/* B · METAL: liquid metal. A brushed chrome capsule that pours like mercury, a black mirror lens, black type */
[data-look="metal"] {
  --page: var(--n-975);
  --field-bg: radial-gradient(ellipse 75% 45% at 50% 58%, var(--n-800) 0%, var(--n-950) 62%, var(--n-975) 100%);
  --on-field: var(--n-0); --on-field-muted: var(--ch-400); --chip: var(--ch-100); --on-chip: var(--n-1000); --em: var(--ch-300); --avatar-ring: var(--ch-300); --lockup: none;
  --win: var(--w-6); --win-edge: var(--w-12); --win-shadow: 0 50px 90px -30px var(--n-1000); --on-win: var(--ch-400); --win-dot: var(--w-18);
  --site: var(--n-950); --on-site: var(--n-0); --on-site-muted: var(--ch-400); --on-site-soft: var(--ch-300); --site-line: var(--w-12); --site-card: var(--w-4); --site-dots: var(--w-9); --site-btn: linear-gradient(180deg, var(--ch-50), var(--ch-300)); --on-site-btn: var(--n-1000);
  --light: rgb(255 255 255 / .12); --beam: rgb(255 255 255 / .06); --vscrim: rgb(0 0 0 / .5);
  --glass: repeating-linear-gradient(0deg, var(--w-12) 0 1px, transparent 1px 3px),
           linear-gradient(180deg, var(--ch-50) 0, var(--ch-200) 20px, var(--ch-300) 32px, var(--ch-100) 50px, var(--ch-300) 58px, var(--ch-200) 60px, var(--ch-300) 100%);
  --glass-filter: none;
  --glass-rim: inset 0 1px 0 var(--n-0), inset 0 -1px 0 var(--b-40), inset 0 0 0 1px var(--b-25), inset 0 -12px 20px -12px var(--b-40);
  --glass-drop: 0 34px 70px -26px var(--n-1000), 0 0 0 1px var(--b-70);
  --pane-r: 29px; --spot: rgb(255 255 255 / .95); --rim: var(--n-0); --sheen: rgb(255 255 255 / .9);
  --lens-bg: linear-gradient(180deg, var(--ch-700) 0%, var(--n-1000) 55%, var(--ch-800) 100%); --lens-filter: none;
  --lens-rim: inset 0 1px 0 var(--w-40), inset 0 0 0 1px var(--b-70), 0 6px 14px -6px var(--b-70);
  --on-lens: var(--n-0); --on-lens-muted: var(--ch-300);
  --on-bar: var(--n-1000); --on-bar-muted: var(--ch-700); --focus: var(--n-1000);
  --cta: var(--n-1000); --on-cta: var(--n-0); --cta-glow: 0 6px 16px -8px var(--b-70); --burger: linear-gradient(180deg, var(--ch-50), var(--ch-300));
  --on-pane: var(--n-1000); --on-pane-muted: var(--ch-700); --pane-head: var(--ch-700); --pane-rule: var(--b-12);
  --ico: linear-gradient(180deg, var(--ch-50), var(--ch-300)); --ico-edge: var(--b-25); --on-ico: var(--n-1000);
  --feat: linear-gradient(160deg, var(--ch-700), var(--n-1000) 60%, var(--ch-800)); --on-feat: var(--n-0); --on-feat-muted: var(--ch-300); --chart: linear-gradient(180deg, var(--ch-50), var(--ch-500));
  --code: var(--n-1000); --on-code: var(--ch-100); --on-code-muted: var(--ch-400);
  --badge: var(--n-1000); --on-badge: var(--n-0);
  --foot-fade: rgb(198 200 206 / .96); --foot-sign: var(--ch-100); --grip: var(--b-25);
  --word: var(--ch-100); --word-glow: var(--w-12);
  --bloom: rgb(255 255 255 / .5); --ripple: var(--ch-200);
  --pill: var(--w-6); --pill-edge: var(--w-12); --on-pill: var(--n-0); --on-pill-muted: var(--ch-400); --pill-icon: var(--ch-100); --on-pill-icon: var(--n-1000);
}
style.css — the looks' own touches
/* ---- 14. the looks' own touches ---- */
/* metal: the word is chrome-white and heavier; the drop's reflections are brighter and sharper */
[data-look="metal"] .w { font-family: var(--sans); font-style: normal; font-weight: 700; font-size: 74px; letter-spacing: -.055em; }
[data-look="metal"] .g-spot { background: linear-gradient(100deg, transparent calc(var(--mx, 50%) - 110px), var(--pn-spot) var(--mx, 50%), transparent calc(var(--mx, 50%) + 110px)); mix-blend-mode: overlay; }
[data-look="metal"] .g-sheen { width: 70%; background: linear-gradient(100deg, transparent 0%, var(--pn-sheen) 46%, var(--n-0) 50%, var(--pn-sheen) 54%, transparent 100%); mix-blend-mode: overlay; }
[data-look="metal"] .brand .b1 { fill: var(--n-1000); }
/* island: solid, no glass — no rim light, no spotlight, no lens glints; an upright word */
[data-look="island"] .w { font-style: normal; font-size: 80px; letter-spacing: -.03em; }
[data-look="island"] .g-spot, [data-look="island"] .g-rim,
[data-look="island"] .hl::before, [data-look="island"] .hl::after, [data-look="island"] .lens::before, [data-look="island"] .lens::after { display: none; }
[data-look="island"] .lights::before { display: none; }
Look · glassOpen ↗
Look · metalOpen ↗
Look · islandOpen ↗

Make it yours

  • Looks are tokens: pick one with ?look=glass, metal or island (or a, b, c). ?w=720 sets the demo window's width, ?open=product opens a section, and ?full=1 shows the bare component at the real size of your browser window.
  • The content is plain HTML: add a section by adding an <li class="it"> with a trigger and a panel; its blocks slide on their own because they are the panel grid's children. Each section's columns are set in the desktop and tablet container queries.
  • The breakpoints live only in style.css: change the container queries and the script follows, because it reads --mode instead of measuring the screen. The nav follows its container, so it works in a sidebar layout or an embed as well as full width.
  • The feel is in FEEL: the drop's leading and trailing springs, the lens's, how much the drop softens at speed, whether content focuses in, and whether the drop reshapes in two strokes. The hover intent delays are INTENT_OPEN, INTENT_SWITCH and INTENT_CLOSE.
  • Accessibility is already there: disclosure buttons with aria-expanded and aria-controls, labelled regions, inert on what is hidden, arrow keys, Home/End and Escape, a modal sheet with a focus trap on a phone, tap instead of hover on touch, visible focus rings, a slider for the window, and prefers-reduced-motion.
  • Nothing goes anywhere. Every link is #; a real site puts its URLs in the panels and keeps the rest.
The one idea worth keeping

Make the bar and the panel the same object. One box with four sprung edges is the capsule when closed and the panel when open, so the menu pours out of the bar instead of a dropdown appearing under it, and moving between sections reshapes it instead of swapping panels. And let the stylesheet own the layout: container queries write --mode, and the script only reads it.