docs / sula / sula-nav

SulaNav

a row of views that behaves like one body of liquid: one view opens into its bar of section tabs, the rest sit collapsed as icon pills, and the shapes merge and split as you move between them.

intensity · assertsrsc · client

provenance

authored in usva

layer

sula

intensity

asserts · one per region. it is the focal point

composition

one per page, fixed in a header that centers itsearch and theme controls ride along as satellites, one material with the barnever a second sula in the nav's region; satellites exist so nothing sits beside itnot an in-page tab strip. SulaSegmented switches content, this switches routes

a11y

a labelled nav landmark · the active tab carries aria-current="page" · melted sides are inert · the canvas is aria-hiddenjest-axe

dependencies

motion · ogl · sula-core and sula-motion from the same package
live demo · try it out
customize
fluidfalse mounts no canvas, plain css pills
sidesOpenmelt brand and pills back into the active bar
satellitesa search and a theme control, one material with the bar
shine0 matte glass, 1 full neon rim
0.7
mergeRadiushow eagerly the parts merge, in px
14
revealDelayms after landing before the sides emerge
120
usagecopy
import { SulaNav } from "usva/sula/sula-nav";
import Link from "next/link";

<header className="fixed inset-x-0 top-0 z-50 flex justify-center p-4">
  <SulaNav
    linkComponent={Link}
    brand={<span>acme</span>}
    brandLabel="acme home"
    activeView={view}
    onViewChange={setView}
    activeItem={section}
    onNavigate={setSection}
    views={views}
  />
</header>

props

proptypedefaultnotes
viewsSulaNavView[]each entry is { href, label, icon, items? }. one expands to its bar of section tabs; the rest collapse to icon pills.
activeViewstringfirst viewcontrolled: the href of the expanded view. derive it from your router.
onViewChange(href: string) => voidfires when a collapsed view pill is clicked; the old bar melts down while the clicked one swells up.
activeItemstringcontrolled: the active section tab inside the expanded view. drive it from a scroll-spy.
onNavigate(href: string) => voidfires on a section-tab click, alongside the link's own navigation.
linkComponentReact.ElementType"a"pass next/link or a NavLink.
brand / brandHref / brandLabelReactNode · string · stringbrandHref: "/"the leftmost pill: a wordmark linking to brandHref. needs brandLabel to be named.
satellitesSulaNavSatellite[]fields that split off the body and settle in a corner: search, theme. handing them here keeps them one material with the bar.
labelsFrom"sm" | "md" | "lg" | "xl""sm"below this width the item labels fold away and the tabs are icons.
collapseBelow"sm" | "md" | "lg"below this width the routes and satellites fold into a single menu droplet and the body swells open into a panel. nothing is hidden.
menuLabelstring"Menu"accessible name of the menu droplet.
offsetnumber0vertical nudge in px from the nav's anchor: positive is down.
ariaLabelstring"Primary"names the nav landmark.
fluidbooleantruefalse renders plain CSS pills and mounts no canvas. reduced motion and missing WebGL2 take the same path.
backdrop / tint / accentColorstringbg · surface · accent tokenswhat the glass tints against, the glass itself, and the rim light. re-read on theme change.
shinenumbertheme0 is flat matte glass, 1 is the full neon rim. dark themes glow, pale ones stay subtle.
mergeRadiusnumber14how eagerly the parts merge, in pixels.
revealDelaynumber120milliseconds after the bar lands before the sides emerge.
sidesOpenbooleantruewhether the brand, pills and satellites are out. toggle from scroll to melt everything but the active bar back in; a hidden part is not tabbable.

get it

npx shadcn add https://usva.build/r/sula-nav.jsoncopy
source · components/ui/nav-geometry.tsexactly what this command copiescopy
import {
  type Blob,
  BRIDGE_REACH,
  NECK_MIN,
  type Neck,
} from "./geometry";
import {
  c1Settle,
  clamp01,
  easeOutCubic,
  mix,
  smoother,
  smoothstep,
} from "./curves";

/** The drop separates from the edge across this window of the fall, while its
 * neck is still short, then falls the rest of the way free. Pinching early is
 * what keeps a long thread from ever spanning the gap. */
const SEPARATE_START = 0.5;
const SEPARATE_END = 0.9;
/** The leftover top mass has fully recoiled into the edge by here in the retract. */
const EDGE_HIDE_START = 0.7;

export interface LoadShape {
  bar: Blob;
  extras: Blob[];
  necks: Neck[];
}

/** A small, frame-rate-independent deformation that makes moving blobs yield
 * like fluid while returning to the exact authored shape at both endpoints. */
export function flowStretch(
  blob: Blob,
  progress: number,
  vertical: boolean,
): Blob {
  const wave = Math.sin(Math.PI * clamp01(progress)) ** 2;
  if (wave <= 0.000001) return blob;
  const stretch = wave * 0.055;
  const along = 1 + stretch;
  const across = 1 - stretch * 0.4;
  const hw = blob.hw * (vertical ? across : along);
  const hh = blob.hh * (vertical ? along : across);
  return { ...blob, hw, hh, r: Math.min(blob.r, hw, hh) };
}

/**
 * The bar is drawn down out of the top edge as a teardrop. A small bulb gathers
 * at the edge, the drop stretches away on a short neck, and it separates early in
 * the fall: the neck pinches off while it is still short and the top bulb recoils
 * into the edge, so the drop finishes falling free rather than trailing a thread.
 * `t` may pass 1: a spring's overshoot carries the bar past its rest line so it
 * lands with a dip and a soft second bob instead of decelerating into a wall.
 */
export function loadPhase(
  barRest: Blob,
  t: number,
  edgeY: number,
  retractT: number = 1,
): LoadShape {
  const p = clamp01(t);
  const retract = clamp01(retractT);
  const cx = barRest.cx;

  const gather = smoothstep(0, 0.14, p);
  const startCy = edgeY + barRest.hh * 0.55;
  /* One continuous fall. The eased launch reaches the rest line at t=1 already
   * moving (slope 1), so the spring's overshoot past 1 flows straight on past the
   * line and back as one motion, instead of the bar easing to a dead stop at the
   * line and only then starting a separate dip. `t` is the raw spring value and
   * carries the overshoot; f is its delayed, unit-landing fall time. */
  const fall = c1Settle(t, 0.1);
  const cy = mix(startCy, barRest.cy, fall);
  const hw = barRest.hw * mix(0.3, 1, smoothstep(0.3, 0.85, p));
  const hh = barRest.hh * mix(0.42, 1, smoothstep(0.2, 0.8, p));
  const bar: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };

  if (retract >= 1) return { bar, extras: [], necks: [] };

  /* The drop lets go of the edge early in its fall, while the neck is still
   * short, then falls free; the leftover top mass recoils into the edge as it
   * separates. So nothing ever spans the gap as a long thread. Recoil is driven
   * by the fall itself, and the retract only mops up any remainder. */
  const separate = smoothstep(SEPARATE_START, SEPARATE_END, p);
  const recoil = Math.max(separate, smoothstep(EDGE_HIDE_START, 1, retract));

  /* A wide, shallow fluid lens at the edge that necks down into the bar, not a
   * bead: the width is what reads as a gooey surface being pulled, and it drains
   * to nothing as the drop separates. */
  const swell = mix(0.78, 1, gather);
  const bulbHw = barRest.hw * mix(0.52, 0, recoil) * swell;
  const bulbHh = barRest.hh * mix(0.92, 0, recoil) * swell;
  const reservoir: Blob = {
    cx,
    cy: edgeY + bulbHh * 0.5,
    hw: bulbHw,
    hh: bulbHh,
    r: Math.min(bulbHw, bulbHh),
  };

  /* A fat neck, so the smooth-min bends the surface into a concave funnel with
   * real surface tension instead of a thread. It thins and its strength fades as
   * the drop separates, so the fat column necks in the middle and pinches. */
  const barTop = cy - hh;
  const tetherRadius = barRest.hh * mix(1.15, 0.22, separate);
  const tetherStrength = 1 - separate;
  const necks: Neck[] =
    barTop > edgeY + 2 && tetherStrength > 0.001
      ? [
          {
            ax: cx,
            ay: edgeY,
            bx: cx,
            by: barTop,
            r: tetherRadius,
            strength: tetherStrength,
          },
        ]
      : [];

  const extras = bulbHw > 0.001 && bulbHh > 0.001 ? [reservoir] : [];
  return { bar, extras, necks };
}

/** The side first redistributes mass into the bar's shoulder without travelling.
 * A longer swell reads as surface tension: the pill clings and gathers before it
 * lets go, rather than shooting straight out. */
const SIDE_SWELL_END = 0.36;
/** Travel overlaps the end of the swell so the shoulder pours outward. */
const SIDE_TRAVEL_START = 0.3;
/** The side is already home when its tether pinches free. */
const SIDE_PINCH_START = 0.76;
const SIDE_PINCH_END = 0.97;

/** Past this much free length, in CSS px, a neck has stopped being surface
 * tension and started being a thread, so it thins; by the break it is gone.
 * This is what lets one part travel much further than its neighbours: a pill
 * sitting next to the bar never stretches this far and keeps the travel-driven
 * pinch above, while a satellite bound for a corner separates near the body it
 * came from and covers the rest of the distance free. Without it, a part that
 * flies 500px out drags a fat capsule behind it and the bar reads as one slab
 * spanning the whole viewport. */
const NECK_STRETCH_FREE = 0;
const NECK_STRETCH_BREAK = 104;

/** How much of the bridge's remaining slack the settle wobble is allowed to
 * spend. The rest is margin: the merge radius breathes with velocity, so a
 * bounce that lands exactly on the reach would still flicker the neck. */
const BOUNCE_OF_SLACK = 0.75;

/**
 * A side emerges the way a drop separates from a larger one. At rest it is fully
 * absorbed inside the bar's end, so nothing pokes out and the closed nav has clean
 * edges. As it opens it first swells out past the end while still merged (the end
 * bulges), then travels out trailing a neck that thins until it snaps. Melting it
 * back runs the same path in reverse, ending flush inside the bar. It never pops.
 *
 * The neck pinches on whichever comes first: the end of the travel, or the neck
 * stretching past what surface tension holds. A neighbouring pill lands still
 * attached and lets go on arrival; a satellite bound for a corner lets go early
 * and flies the rest of the way as a separate field.
 */
export function revealSide(
  bar: Blob,
  rest: Blob,
  t: number,
  k: number,
): { blob: Blob; neck: Neck | null } {
  const p = clamp01(t);
  const dir = Math.sign(rest.cx - bar.cx) || 1;
  const barEnd = bar.cx + bar.hw * dir;

  const swell = smoothstep(0, SIDE_SWELL_END, p);
  /* Travel reaches the rest line at t=1 still moving (slope 1), so an underdamped
   * SIDE_SPRING carries a small settle wobble past the line and back instead of the
   * pill landing hard. Raw t, not clamped p, so the overshoot is not flattened. */
  const travel = c1Settle(t, SIDE_TRAVEL_START);

  const scale = mix(0.5, 1, swell);
  const hw = rest.hw * scale;
  const hh = rest.hh * scale;

  const startCx = barEnd - dir * rest.hw * 0.62;
  const rawCx = mix(startCx, rest.cx, travel);

  const rawInner = rawCx - dir * hw;
  const stretch = Math.abs(rawInner - barEnd);
  const pinch = Math.max(
    smoothstep(SIDE_PINCH_START, SIDE_PINCH_END, p),
    smoothstep(NECK_STRETCH_FREE, NECK_STRETCH_BREAK, stretch),
  );

  /* The spring's outer turn must not tear the standing bridge: at the turning
   * point velocity hits zero, so the live merge radius relaxes too, and an
   * overshoot past the bridge's reach disconnects the body for one frame and
   * reconnects it on the rebound.
   *
   * But how far the pill may bounce is not a constant, it is whatever slack the
   * bridge has left. A flat cap spent the same tiny budget on a pill sitting
   * against the bar and on one thrown at a corner, so the throw landed dead on
   * its rest line: the eye reads that as the spring hitting a wall, not as an
   * underdamped body settling. A satellite that comes to rest beyond the reach
   * has no bridge to protect at all, and is free to overshoot as far as the
   * spring carries it. */
  const restGap = Math.abs(rest.cx - bar.cx) - rest.hw - bar.hw;
  const slack = k * BRIDGE_REACH - restGap;
  const cx =
    slack <= 0
      ? rawCx
      : dir > 0
        ? Math.min(rawCx, rest.cx + slack * BOUNCE_OF_SLACK)
        : Math.max(rawCx, rest.cx - slack * BOUNCE_OF_SLACK);
  const cy = mix(bar.cy, rest.cy, travel);
  const blob: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };

  if (travel <= 0) return { blob, neck: null };
  if (pinch >= 1) return { blob, neck: null };
  const inner = cx - dir * hw;
  const tail = 1 - pinch;
  const r = rest.hh * 0.86 * tail;
  const strength = tail * tail;
  if (r < 0.2 || strength < 0.0001) return { blob, neck: null };
  const neck: Neck = {
    ax: barEnd,
    ay: bar.cy,
    bx: inner,
    by: cy,
    r,
    strength,
  };
  return { blob, neck };
}

/** Leading and trailing start merged inside the bar and spring out, each trailing a neck. */
export function revealPhase(
  bar: Blob,
  leadRest: Blob,
  trailRest: Blob,
  t: number,
  k: number,
): { lead: Blob; trail: Blob; necks: Neck[] } {
  const lead = revealSide(bar, leadRest, t, k);
  const trail = revealSide(bar, trailRest, t, k);
  const necks = [lead.neck, trail.neck].filter(
    (neck): neck is Neck => neck !== null,
  );
  return { lead: lead.blob, trail: trail.blob, necks };
}

/** The panel deepens first and only then spreads, so it reads as the body being
 * pulled down and then letting go sideways, not as a box scaling up. */
const SWELL_SPREAD_START = 0.16;
/** How far into the droplet the panel's top edge starts, as a fraction of the
 * droplet's half-height. Overlapping means the two are one surface from the
 * first frame: the panel is never a second shape that flies in and docks. */
const SWELL_ROOT = -0.35;

/**
 * The menu panel grows out of the collapsed nav's own body. Where `revealSide`
 * splits a droplet off and sends it away, this is the same material moving the
 * other way: the body swells downward and stretches open, staying attached the
 * whole time. Its top edge is pinned inside the droplet it came from, so it
 * cannot read as a drawer arriving from somewhere else; only the bottom edge
 * travels. Closing runs the identical path in reverse and ends flush inside the
 * droplet, at which point nothing of it is left to see.
 *
 * `t` carries the spring's overshoot past 1: the bottom edge dips past the rest
 * line and comes back, which is the panel landing with weight.
 */
export function swellPanel(
  source: Blob,
  rest: Blob,
  t: number,
): { blob: Blob; neck: Neck | null } {
  const p = clamp01(t);
  const deep = c1Settle(t, 0);
  const wide = smoothstep(SWELL_SPREAD_START, 1, p);

  const top = source.cy + source.hh * SWELL_ROOT;
  const bottom = mix(top, rest.cy + rest.hh, deep);
  const hh = Math.max((bottom - top) / 2, 0.5);
  const rootHw = mix(
    0.5,
    source.hw * 0.72,
    smoothstep(0, SWELL_SPREAD_START, p),
  );
  const hw = Math.max(mix(rootHw, rest.hw, wide), 0.5);
  const cx = mix(source.cx, rest.cx, wide);
  const blob: Blob = {
    cx,
    cy: top + hh,
    hw,
    hh,
    r: Math.min(rest.r, hw, hh),
  };

  if (deep <= 0.001) return { blob, neck: null };
  /* A fat, short column between the droplet and the panel's shoulder. Its
   * strength melts through the closing tail so the resting droplet never
   * inherits a one-frame swollen outline. */
  const r = Math.max(mix(source.hh * 0.9, source.hw * 0.62, wide), NECK_MIN);
  const neck: Neck = {
    ax: source.cx,
    ay: source.cy,
    bx: cx,
    by: Math.min(top + hh, top + source.hh),
    r,
    strength: smoothstep(0.02, 0.28, p),
  };
  return { blob, neck };
}

/** The panel's own contents arrive only once there is material to hold them.
 * Late on purpose: the row nearest the bottom edge is the last thing the swell
 * reaches, and a label lit before the glass is under it floats in mid air. */
export function swellFade(t: number): number {
  return smoother(smoothstep(0.62, 0.96, clamp01(t)));
}

export type SwitchRole = "hide" | "show" | "keep";

/** The collapsing pill's whole morph fits inside this fraction of the switch. */
const HIDE_SPAN = 0.62;
/** The expanding pill holds until this fraction, then fills. */
const SHOW_LAG = 0.24;

const roleSpan = (t: number, role: SwitchRole): number => {
  const x = clamp01(t);
  if (role === "hide") return clamp01(x / HIDE_SPAN);
  if (role === "show") return clamp01((x - SHOW_LAG) / (1 - SHOW_LAG));
  return x;
};

/**
 * A draining pill leads the morph and a filling pill lags it, so the two are
 * never wide at the same moment and the row cannot fuse into one ballooning
 * slab mid-switch. The outgoing bar collapses on a fast ease-out so it never
 * lingers wide; the incoming bar pours out on the gentler quintic. Each blob
 * still moves monotonically between its endpoints.
 */
export function switchProgress(t: number, role: SwitchRole): number {
  const s = roleSpan(t, role);
  return role === "hide" ? easeOutCubic(s) : smoother(s);
}

/**
 * Content is swapped in the DOM the instant the view changes, so each changing
 * part already holds its target label. A monotonic fade-in materialises that new
 * label as the part reshapes; the old 1 to 0 to 1 dip flashed already-correct
 * content for nothing. Width staggers per role, but the fade stays on the gentle
 * quintic so the label never snaps in. Unchanged parts stay lit.
 */
export function switchFade(t: number, role: SwitchRole): number {
  return role === "keep" ? 1 : smoother(roleSpan(t, role));
}
source · components/ui/sula-nav.tsxexactly what this command copiescopy
"use client";
import { animate, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  createField,
  type FieldColors,
  resolveColor,
  shineForBackdrop,
} from "./field";
import {
  type Blob,
  bridgeNecks,
  measureRestBlobs,
  morphBlob,
  type Neck,
  packHover,
  packUniforms,
  restDiffers,
} from "./geometry";
import { createPauseGate } from "./pause";
import { useContextRecovery } from "./recovery";
import { useFieldRetune } from "./retune";
import { clamp01, smoother, smoothstep } from "./curves";
import { createEnergyTracker } from "./energy";
import {
  barSpring,
  dripRetract,
  menuRetract,
  menuSwell,
  type SulaMotionSpec,
  sideSpring,
  switchSpring,
  textFade,
} from "./springs";
import {
  flowStretch,
  loadPhase,
  revealSide,
  type SwitchRole,
  swellFade,
  swellPanel,
  switchFade,
  switchProgress,
} from "./nav-geometry";

/** A section-indicator tab shown inside an expanded view. */
export interface SulaNavItem {
  href: string;
  label: string;
  icon?: React.ReactNode;
  /** On the tab itself, so a dense route set can drop its secondary entries at a
   * breakpoint rather than overflowing the bar. */
  className?: string;
}

/** A navigable view: collapsed to an icon pill, expanded to a bar of its items. */
export interface SulaNavView {
  href: string;
  label: string;
  icon: React.ReactNode;
  items?: SulaNavItem[];
}

/**
 * A field that separates off the nav's body and settles in a corner: a control
 * that belongs to the nav but does not belong in the middle of it, like a search
 * trigger or a theme control.
 *
 * It is not a second component. It is the same body, split. One renderer paints
 * the whole bar, so a satellite is a disjoint field of the nav's own material
 * rather than a widget parked beside it, and the separation is what the reveal
 * animates. Peer components crammed into the bar would put several liquid fields
 * in one region, competing for the same attention; a satellite adds none.
 */
export interface SulaNavSatellite {
  id: string;
  /** Which corner it flows out to. Defaults to the right. */
  align?: "left" | "right";
  /** Names the group for assistive tech, since the contents are yours. */
  label: string;
  children: React.ReactNode;
}

/** The width at which item labels unfold; below it the tabs are icon-only. */
export type SulaNavLabelsFrom = "sm" | "md" | "lg" | "xl";

/** The width below which the bar folds into a menu droplet. */
export type SulaNavCollapseBelow = "sm" | "md" | "lg";

const BREAKPOINT_PX: Record<SulaNavCollapseBelow, number> = {
  sm: 640,
  md: 768,
  lg: 1024,
};

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

function useBelow(breakpoint?: SulaNavCollapseBelow): boolean {
  const [below, setBelow] = React.useState(false);
  useIsomorphicLayoutEffect(() => {
    if (!breakpoint) {
      setBelow(false);
      return;
    }
    const query = window.matchMedia(
      `(max-width: ${BREAKPOINT_PX[breakpoint] - 0.02}px)`,
    );
    const sync = () => setBelow(query.matches);
    sync();
    query.addEventListener("change", sync);
    return () => query.removeEventListener("change", sync);
  }, [breakpoint]);
  return below;
}

const LABEL_COLLAPSE: Record<SulaNavLabelsFrom, string> = {
  sm: "max-sm:max-w-0 max-sm:overflow-hidden max-sm:opacity-0",
  md: "max-md:max-w-0 max-md:overflow-hidden max-md:opacity-0",
  lg: "max-lg:max-w-0 max-lg:overflow-hidden max-lg:opacity-0",
  xl: "max-xl:max-w-0 max-xl:overflow-hidden max-xl:opacity-0",
};

const COLLAPSE_GUARD: Record<SulaNavCollapseBelow, string> = {
  sm: "max-sm:invisible",
  md: "max-md:invisible",
  lg: "max-lg:invisible",
};

export interface SulaNavProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  views: SulaNavView[];
  /** href of the expanded view. Controlled. Defaults to the first view. */
  activeView?: string;
  onViewChange?: (href: string) => void;
  /** href of the active section tab inside the expanded view. Controlled. */
  activeItem?: string;
  onNavigate?: (href: string) => void;
  linkComponent?: React.ElementType;

  brand?: React.ReactNode;
  brandHref?: string;
  brandLabel?: string;

  /**
   * Fields that split off the body and settle in a corner. Handing them here
   * rather than rendering them beside the nav is what keeps them one material
   * with it: they are measured, revealed and painted as parts of this field.
   */
  satellites?: SulaNavSatellite[];
  /** Below this width the item labels fold away and the tabs are icons. */
  labelsFrom?: SulaNavLabelsFrom;

  /**
   * Below this width the routes and the satellites fold into a single menu
   * droplet, and the nav's own body swells open into a panel holding them. The
   * nav becomes the menu: it is not a drawer arriving beside it, which would put
   * a second liquid field in the nav's region. Nothing is hidden to make room,
   * because hiding routes at the one width where the bar is most constrained is
   * amputation, not responsiveness.
   */
  collapseBelow?: SulaNavCollapseBelow;
  /** Accessible name of the menu droplet. */
  menuLabel?: string;

  /** Vertical nudge in px from the nav's anchor: positive is down, negative up. */
  offset?: number;

  ariaLabel?: string;
  fluid?: boolean;
  backdrop?: string;
  tint?: string;
  accentColor?: string;
  /** 0 is flat matte glass, 1 is the full neon rim. Defaults to the theme. */
  shine?: number;
  mergeRadius?: number;
  revealDelay?: number;
  /**
   * Holds the reveal back this many ms before it begins, for a page that opens
   * behind a cover. The bar stays in the markup throughout, so nothing about the
   * document changes: only the moment the liquid starts moving.
   */
  revealAfter?: number;
  /**
   * Whether the brand and the collapsed view pills are out. Defaults to true and
   * reveals on mount. Drive it from scroll or focus to melt everything but the
   * active bar back in; a hidden part is not tabbable.
   */
  sidesOpen?: boolean;
}

/**
 * The canvas is anchored to the nav, not to the viewport, so the bar falls out of
 * the canvas's own top edge. Kept compact so the drip forms just above the bar and
 * separates near it, rather than trailing a long thread down the whole card.
 */
const DROP_HEIGHT = 124;
/** The shortest fall worth drawing. The drop's edge is never pushed closer to the
 * bar than this, so even a nav pinned tight to the top of the window pours rather
 * than simply materialising. */
const MIN_FALL = 50;
/** How far above the visible edge the liquid is sourced. The gathering bulb is
 * the ugly part of a drip: on screen it reads as a stray pill parked at the top
 * of the window, with nothing holding it up. Sourced this far above the edge, the
 * bulb forms out of frame and only the fall, the neck and the pinch are seen,
 * which is the whole illusion: the material comes from somewhere off-page. */
const EDGE_ABOVE = 32;
/** How far below the nav the canvas reaches, so a neck can hang past the pills. */
const CANVAS_SLACK = 64;
/** How far below the nav the canvas reaches while the bar is collapsed, so the
 * swollen panel and its overshoot are inside the field. A constant, not the
 * panel's measured height: resizing the canvas mid-swell would re-init the field
 * in the middle of the animation. The panel caps its own height to stay under it. */
const PANEL_REACH = 560;
/** The panel's corner radius. Its blob carries the same one, so the painted
 * material and the DOM box are the same shape. */
const PANEL_RADIUS = 28;
/** Horizontal room past the nav, so a pill's cap and neck are not clipped. */
const SLACK_X = 104;
const MAX_DPR = 2;

/** The bar's labels start fading in at this much of the drop, not on settle. */
const TEXT_AT = 0.75;
/** The tether lets go the moment the bar first reaches its line, mid-settle,
 * instead of waiting out the spring's long formal tail. */
const DRIP_AT = 0.97;
/** Peak surface undulation, in px of edge displacement. Kept below the authored
 * stretch/squash motion so long bars read as fluid glass, not a noisy coastline. */
const WOBBLE_MAX = 0.24;
/** How far the row compresses toward its centre at the peak of a switch. */
const PULL_FRAC = 0.06;
/** Merge floor: adjacent parts within reach hold a soft surface-tension waist at
 * rest, so the row reads as parts pulling toward each other rather than floating
 * apart. Kept low so it never fuses into one slab. */
const REST_MERGE = 0.32;
/** Bridge-neck reach as a multiple of the merge radius. The switch tail and the
 * rest path must share it: a spring's `.finished` resolves about a second after
 * the visual settle, and only then does the rest path take over, so if it reached
 * for necks any differently the bridges would visibly recalculate a beat late. */
const NECK_REACH = 1.15;
/** Peak edge displacement of the hover ripple, in px. Local to the hovered
 * part, so it can run hotter than the global settle wobble. */
const HOVER_WOBBLE = 1.1;
/** Per-frame ease toward the hover target, in and out. */
const HOVER_EASE = 0.16;

function readColors(
  node: HTMLElement,
  overrides: {
    backdrop?: string;
    tint?: string;
    accent?: string;
    shine?: number;
  },
): FieldColors {
  const styles = getComputedStyle(node);
  const token = (name: string) => styles.getPropertyValue(name).trim();
  const backdrop = resolveColor(overrides.backdrop ?? token("--usva-bg"));
  return {
    backdrop,
    tint: resolveColor(overrides.tint ?? token("--usva-surface")),
    accent: resolveColor(overrides.accent ?? token("--usva-accent")),
    shine: overrides.shine ?? shineForBackdrop(backdrop),
  };
}

/** `toCanvasSpace` gives every box a stadium radius, which is right for a pill and
 * wrong for a panel: it would round a tall box into a lozenge. */
const panelBlob = (rect: DOMRect, box: DOMRect): Blob => {
  const hw = rect.width / 2;
  const hh = rect.height / 2;
  return {
    cx: rect.left - box.left + hw,
    cy: rect.top - box.top + hh,
    hw,
    hh,
    r: Math.min(PANEL_RADIUS, hw, hh),
  };
};

const shift = (blob: Blob, rest: Blob): string =>
  `translate3d(${blob.cx - rest.cx}px, ${blob.cy - rest.cy}px, 0)`;

/** The pills and brand fade up once the sides are most of the way out. */
const labelFade = (t: number): number => {
  const s = clamp01((t - 0.82) / 0.16);
  return s * s * (3 - 2 * s);
};

export const SulaNav = React.forwardRef<HTMLElement, SulaNavProps>(
  (
    {
      views,
      activeView,
      onViewChange,
      activeItem,
      onNavigate,
      linkComponent: Link = "a",
      brand,
      brandHref = "/",
      brandLabel,
      satellites,
      labelsFrom = "sm",
      collapseBelow,
      menuLabel = "Menu",
      offset = 0,
      ariaLabel = "Primary",
      fluid = true,
      backdrop,
      tint,
      accentColor,
      shine,
      mergeRadius = 14,
      revealDelay = 120,
      revealAfter = 0,
      sidesOpen = true,
      className,
      style,
      ...props
    },
    forwardedRef,
  ) => {
    const reduced = useReducedMotion();
    const navRef = React.useRef<HTMLElement | null>(null);
    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
    const stageRef = React.useRef<HTMLDivElement | null>(null);
    const brandRef = React.useRef<HTMLDivElement | null>(null);
    const viewRefs = React.useRef<Array<HTMLDivElement | null>>([]);
    const itemRefs = React.useRef<Record<string, HTMLLIElement | null>>({});
    const satRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
    const panelRef = React.useRef<HTMLDivElement | null>(null);
    const panelBodyRef = React.useRef<HTMLDivElement | null>(null);

    const collapsed = useBelow(collapseBelow);
    const panelId = React.useId();
    const [menuOpen, setMenuOpen] = React.useState(false);
    const setMenuRef = React.useRef<(open: boolean) => void>(() => {});
    React.useEffect(() => {
      if (!collapsed) setMenuOpen(false);
    }, [collapsed]);

    const leftSats = React.useMemo(
      () => (satellites ?? []).filter((s) => s.align === "left"),
      [satellites],
    );
    const rightSats = React.useMemo(
      () => (satellites ?? []).filter((s) => s.align !== "left"),
      [satellites],
    );
    const hasSatellites = leftSats.length > 0 || rightSats.length > 0;
    const leftKey = leftSats.map((s) => s.id).join(" ");
    const rightKey = rightSats.map((s) => s.id).join(" ");

    const { failed, generation, onContextLost, onContextReady } =
      useContextRecovery(canvasRef);
    const [mounted, setMounted] = React.useState(false);
    useIsomorphicLayoutEffect(() => setMounted(true), []);

    const isFluid = fluid && !reduced && !failed && mounted;
    const fluidShell = fluid && !failed && (!mounted || !reduced);
    const keepCanvas = fluid && (!mounted || !reduced);

    const resolvedActiveView = activeView ?? views[0]?.href;
    const activeViewIndex = Math.max(
      0,
      views.findIndex((view) => view.href === resolvedActiveView),
    );
    const activeItems = views[activeViewIndex]?.items ?? [];

    const activeIndexRef = React.useRef(activeViewIndex);
    activeIndexRef.current = activeViewIndex;
    const leftCountRef = React.useRef(leftSats.length);
    leftCountRef.current = collapsed ? 0 : leftSats.length;
    const switchRef = React.useRef<(previous: number) => void>(() => {});
    const setSidesRef = React.useRef<(open: boolean) => void>(() => {});
    const sidesOpenRef = React.useRef(sidesOpen);
    sidesOpenRef.current = sidesOpen;

    const overrides = React.useMemo(
      () => ({ backdrop, tint, accent: accentColor, shine }),
      [backdrop, tint, accentColor, shine],
    );
    const overridesRef = React.useRef(overrides);
    overridesRef.current = overrides;
    const mergeRadiusRef = React.useRef(mergeRadius);
    mergeRadiusRef.current = mergeRadius;
    const revealDelayRef = React.useRef(revealDelay);
    revealDelayRef.current = revealDelay;
    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);
    const wakeRef = React.useRef<(() => void) | null>(null);

    const [indicator, setIndicator] = React.useState({
      left: 0,
      width: 0,
      ready: false,
    });

    /* A tab the route set drops at this breakpoint measures zero wide, and an
     * indicator pinned to it would read as the first tab being active. */
    React.useLayoutEffect(() => {
      const place = () => {
        const el = activeItem ? itemRefs.current[activeItem] : null;
        if (!el || el.offsetWidth === 0) {
          setIndicator((current) => ({ ...current, ready: false }));
          return;
        }
        setIndicator({
          left: el.offsetLeft,
          width: el.offsetWidth,
          ready: true,
        });
      };
      place();
      window.addEventListener("resize", place);
      return () => window.removeEventListener("resize", place);
    }, [activeItem]);

    /* Left to right, because bridgeNecks reads adjacency out of this order. */
    // biome-ignore lint/correctness/useExhaustiveDependencies: `collapsed` is not read here, it decides which of these nodes exist. Its identity change is what re-inits the field on a breakpoint cross.
    const collectParts = React.useCallback(
      () =>
        [
          brandRef.current,
          ...(leftKey ? leftKey.split(" ") : []).map(
            (id) => satRefs.current[id] ?? null,
          ),
          ...viewRefs.current,
          ...(rightKey ? rightKey.split(" ") : []).map(
            (id) => satRefs.current[id] ?? null,
          ),
        ].filter((node): node is HTMLDivElement => node != null),
      /* `collapsed` changes which nodes exist, so it must give a new identity:
       * the field effect re-inits on it, and the row order it reads is the DOM
       * order it now has. */
      [leftKey, rightKey, collapsed],
    );

    useIsomorphicLayoutEffect(() => {
      if (!isFluid) return;
      const canvas = canvasRef.current;
      const stage = stageRef.current;
      const nav = navRef.current;
      if (!canvas || !stage || !nav) return;

      const field = createField({
        canvas,
        colors: readColors(nav, overridesRef.current),
        onContextLost,
      });
      if (!field) {
        onContextLost();
        return;
      }
      fieldRef.current = field;
      onContextReady();

      let rest: Blob[] = [];
      let panelRest: Blob | null = null;
      /** Blob coordinates are relative to the stage box. A width change re-centres
       * the nav, so the box shifts on screen and the two ends of a switch would be
       * measured in different frames; the last box lets a switch reconcile them. */
      let stageBox: DOMRect | null = null;
      let canvasH = 0;
      let dpr = 1;
      let raf = 0;
      let running = 0;
      let hoverIndex = -1;
      let hoverAmt = 0;
      let hoverBlob: Blob | null = null;
      let hoverPoint = { x: 0, y: 0 };
      let hoverTarget = { x: 0, y: 0 };
      let hasHoverPoint = false;
      const energy = createEnergyTracker();
      const start = performance.now();

      /* The ripple follows the hovered part's live blob; on leave the blob is
       * kept so the ripple fades in place instead of teleporting to zero. */
      const updateHover = (aligned: Array<Blob | undefined>) => {
        const focus =
          hoverIndex >= 0 ? (aligned[hoverIndex] ?? rest[hoverIndex]) : null;
        hoverAmt += ((focus ? 1 : 0) - hoverAmt) * HOVER_EASE;
        if (focus) {
          hoverBlob = focus;
          if (!hasHoverPoint) {
            hoverPoint = { x: focus.cx, y: focus.cy };
            hoverTarget = hoverPoint;
            hasHoverPoint = true;
          }
        }
        hoverPoint.x += (hoverTarget.x - hoverPoint.x) * HOVER_EASE;
        hoverPoint.y += (hoverTarget.y - hoverPoint.y) * HOVER_EASE;
      };

      const bT = { value: 0 };
      const dT = { value: 0 };
      const sT = { value: 0 };
      const swT = { value: 1 };
      const mT = { value: 0 };
      let pMenu = 0;
      let switchFrom: Blob[] = [];
      let switchTo: Blob[] = [];
      let switchRoles: SwitchRole[] = [];
      let switching = false;
      let loaded = false;
      let lastBlobs: Blob[] = [];

      let pBar = 0;
      let pSide = 0;
      let pDrip = 0;
      let pSwitch = 1;
      const barText = { value: 0 };

      let lastW = 0;
      let lastH = 0;
      const measure = (): boolean => {
        const parts = collectParts();

        const box = stage.getBoundingClientRect();
        stageBox = box;
        const width = Math.ceil(box.width);
        const height = Math.ceil(box.height);
        const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
        const sized = width !== lastW || height !== lastH || nextDpr !== dpr;

        if (sized) {
          canvas.style.width = `${width}px`;
          canvas.style.height = `${height}px`;
          field.resize(width, height, nextDpr);
          lastW = width;
          lastH = height;
        }
        canvasH = height;
        dpr = nextDpr;

        const panelNode = panelRef.current;
        const nextPanel = panelNode
          ? panelBlob(panelNode.getBoundingClientRect(), box)
          : null;
        const panelChanged = restDiffers(
          panelRest ? [panelRest] : [],
          nextPanel ? [nextPanel] : [],
        );
        panelRest = nextPanel;

        const next = measureRestBlobs(parts, box);
        if (!sized && !panelChanged && !restDiffers(rest, next)) return false;
        rest = next;
        return true;
      };

      /* Parts run [left satellites, brand, views, right satellites], so the view
       * block starts past whatever sits to its left. */
      const brandOffset = () =>
        leftCountRef.current + (brandRef.current ? 1 : 0);
      const activePart = () => activeIndexRef.current + brandOffset();

      const drawFrame = (blobs: Blob[], necks: Neck[], liveK: number) => {
        if (disposed) return;
        field.draw({
          packed: packUniforms({ blobs, necks, k: liveK }, dpr, canvasH),
          k: liveK * dpr,
          time: (performance.now() - start) / 1000,
          wobble: WOBBLE_MAX * energy.value * energy.value,
          alpha: 1,
          hover:
            hoverBlob && hoverAmt > 0.01
              ? packHover(
                  hoverBlob,
                  hoverAmt * HOVER_WOBBLE,
                  dpr,
                  canvasH,
                  hoverPoint,
                )
              : null,
        });
      };

      /* The panel is not a part. It is the body itself, swollen, so it never
       * joins the row: no index in a switch, no bridge from bridgeNecks, no
       * hover slot. It carries the one neck that holds it to the droplet it grew
       * out of, and that neck never pinches. */
      const menuShape = (
        source: Blob | undefined,
      ): { blobs: Blob[]; necks: Neck[] } => {
        const body = panelBodyRef.current;
        if (!source || !panelRest || mT.value <= 0.0005) {
          if (body) body.style.opacity = "0";
          return { blobs: [], necks: [] };
        }
        const swell = swellPanel(source, panelRest, mT.value);
        if (body) {
          const fade = swellFade(mT.value);
          body.style.opacity = `${fade}`;
          body.style.transform = `translate3d(0, ${(1 - fade) * -10}px, 0)`;
        }
        return { blobs: [swell.blob], necks: swell.neck ? [swell.neck] : [] };
      };

      /* The liquid has to come out of an edge the viewer can actually see. The
       * stage hangs DROP_HEIGHT above the bar to give the drip room, but a nav
       * pinned near the top of the window puts most of that room *above* the
       * window: the bulb gathers, necks and pinches offscreen, and the bar simply
       * appears already landed. So the edge is whichever is lower, the stage's own
       * top or the top of the window, and the drop always pours into view. */
      const visibleEdge = (barRest: Blob): number => {
        const clipped = stageBox ? Math.max(0, -stageBox.top) : 0;
        const latest = Math.max(0, barRest.cy - barRest.hh - MIN_FALL);
        return Math.max(0, Math.min(clipped - EDGE_ABOVE, latest));
      };

      const loadFrame = () => {
        const parts = collectParts();
        const barIndex = activePart();
        const barRest = rest[barIndex];
        if (!barRest) return;

        const vBar = bT.value - pBar;
        const vSide = sT.value - pSide;
        const vDrip = dT.value - pDrip;
        const vMenu = mT.value - pMenu;
        pBar = bT.value;
        pSide = sT.value;
        pDrip = dT.value;
        pMenu = mT.value;

        const load = loadPhase(
          barRest,
          bT.value,
          visibleEdge(barRest),
          dT.value,
        );
        const barBlob = flowStretch(load.bar, bT.value, true);
        /* The row in left-to-right part order (brand, active bar, side pills),
         * so bridgeNecks can hold a neck between each adjacent pair. */
        const rowBlobs: Blob[] = [];
        const aligned: Array<Blob | undefined> = [];
        const necks: Neck[] = [...load.necks];

        const barNode = parts[barIndex];
        if (barNode) {
          barNode.style.transform = shift(load.bar, barRest);
          barNode.style.opacity = `${barText.value}`;
        }

        for (let i = 0; i < parts.length; i++) {
          if (i === barIndex) {
            rowBlobs.push(barBlob);
            aligned[i] = barBlob;
            continue;
          }
          const restI = rest[i];
          if (!restI) continue;
          const side = revealSide(
            load.bar,
            restI,
            sT.value,
            mergeRadiusRef.current,
          );
          const sideBlob = flowStretch(side.blob, sT.value, false);
          rowBlobs.push(sideBlob);
          aligned[i] = sideBlob;
          if (side.neck) necks.push(side.neck);
          const node = parts[i];
          if (node) {
            node.style.transform = shift(side.blob, restI);
            node.style.opacity = `${labelFade(sT.value)}`;
          }
        }

        /* Energy is measured velocity, not a spring's finished promise: a
         * spring reports done long after visible rest, so releasing k and
         * wobble on it reads as a phantom width shift a second late. */
        const speed =
          Math.abs(vBar) + Math.abs(vSide) + Math.abs(vDrip) + Math.abs(vMenu);
        energy.bump(speed);
        const liveK = mergeRadiusRef.current;
        /* Rest tension necks between adjacent row parts. The reach uses a
         * slightly wider k so parts at the default flex gap still neck at rest.
         * They persist because the last drawn frame keeps them once the field
         * parks. Transition necks come first so they win the MAX_NECKS budget.
         * Scaled by how far the sides are out (sT): when they melt in, the merge
         * fades to zero so a lone bar shows no orphan pinch toward a hidden part,
         * and adjacency stays correct because the row order is unchanged. */
        const restMerge = REST_MERGE * smoothstep(0.5, 0.95, sT.value);
        const restNecks =
          restMerge > 0.001
            ? bridgeNecks(rowBlobs, liveK * NECK_REACH, restMerge)
            : [];
        const menu = menuShape(barBlob);
        const blobs: Blob[] = [...rowBlobs, ...load.extras, ...menu.blobs];
        lastBlobs = blobs;
        updateHover(aligned);
        drawFrame(blobs, [...necks, ...restNecks, ...menu.necks], liveK);
      };

      const switchFrame = () => {
        const parts = collectParts();
        const t = swT.value;
        const posT = smoother(t);
        /* The transition envelope is zero at both ends of the switch and peaks in
         * the middle. Necks floor at REST_MERGE so the row keeps its rest surface
         * tension, but the centroid pull rides the raw envelope so it releases
         * fully at t=1; a floored pull would leave the row compressed at rest and
         * jump the moment loadFrame takes over. */
        const transition = Math.sin(Math.PI * clamp01(t));
        const merge = Math.max(REST_MERGE, transition);
        const vSwitch = t - pSwitch;
        pSwitch = t;
        const centroid =
          switchTo.reduce((sum, b) => sum + b.cx, 0) /
          Math.max(1, switchTo.length);

        const blobs: Blob[] = [];
        const aligned: Array<Blob | undefined> = [];
        for (let i = 0; i < parts.length; i++) {
          const from = switchFrom[i];
          const to = switchTo[i];
          if (!from || !to) continue;
          const role = switchRoles[i] ?? "keep";
          const base = morphBlob(from, to, posT, switchProgress(t, role));
          const pulled: Blob = {
            ...base,
            cx: base.cx + (centroid - to.cx) * PULL_FRAC * transition,
          };
          blobs.push(pulled);
          aligned[i] = pulled;
          const node = parts[i];
          if (node) {
            node.style.transform = shift(pulled, to);
            node.style.opacity = `${switchFade(t, role)}`;
          }
        }

        energy.bump(Math.abs(vSwitch));
        const liveK = mergeRadiusRef.current;
        /* Same reach as the rest path, so when the switch spring's `.finished`
         * hands off to loadFrame a beat after settle the necks do not resize. */
        const necks = bridgeNecks(blobs, liveK * NECK_REACH, merge);
        const menu = menuShape(aligned[activePart()]);
        lastBlobs = blobs;
        updateHover(aligned);
        drawFrame([...blobs, ...menu.blobs], [...necks, ...menu.necks], liveK);
      };

      let textStarted = false;
      let dripStarted = false;
      const tick = () => {
        if (switching) switchFrame();
        else loadFrame();

        if (!textStarted && bT.value >= TEXT_AT) {
          textStarted = true;
          run(barText, 1, textFade);
        }
        if (!dripStarted && bT.value >= DRIP_AT) {
          dripStarted = true;
          run(dT, 1, dripRetract);
        }
        if (
          running === 0 &&
          energy.parked() &&
          hoverIndex < 0 &&
          hoverAmt < 0.02
        ) {
          switching = false;
          return;
        }
        raf = requestAnimationFrame(tick);
      };
      /* Resuming re-enters the same tick against live spring values, so a nav
       * that scrolled away mid-reveal picks its drop up where it left it rather
       * than dropping a second time. */
      const wake = () => {
        if (!gate.awake()) return;
        cancelAnimationFrame(raf);
        raf = requestAnimationFrame(tick);
      };
      wakeRef.current = wake;
      const gate = createPauseGate({
        target: nav,
        onPause: () => cancelAnimationFrame(raf),
        onResume: () => wake(),
      });

      const controls = new Set<ReturnType<typeof animate>>();
      const run = (
        target: { value: number },
        to: number | [number, number],
        opts: SulaMotionSpec,
      ) => {
        running += 1;
        const control = animate(target, { value: to }, opts);
        controls.add(control);
        const finish = () => {
          running = Math.max(0, running - 1);
          controls.delete(control);
        };
        void control.finished.then(finish, finish);
        wake();
        return control;
      };

      let disposed = false;
      let revealComplete = false;
      let revealTimer = 0;
      let sideControl: ReturnType<typeof animate> | null = null;
      let swControl: ReturnType<typeof animate> | null = null;
      let switchId = 0;
      let measureQueued = false;

      const setSides = (open: boolean) => {
        if (!revealComplete) return;
        const to = open ? 1 : 0;
        if (sT.value === to) return;
        sideControl?.stop();
        sideControl = run(sT, to, sideSpring);
      };
      setSidesRef.current = setSides;

      let menuControl: ReturnType<typeof animate> | null = null;
      const setMenu = (open: boolean) => {
        const to = open ? 1 : 0;
        if (mT.value === to) return;
        menuControl?.stop();
        /* Explicit keyframes from the live value: motion caches one visual
         * element per subject, so a reversal mid-swell must be told where it is
         * starting from or it snaps to 0 and re-runs. */
        menuControl = run(mT, [mT.value, to], open ? menuSwell : menuRetract);
      };
      setMenuRef.current = setMenu;

      const doSwitch = (previousIndex: number) => {
        if (!loaded) return;
        const currentSwitchId = ++switchId;
        const off = brandOffset();
        const oldBox = stageBox;
        const from =
          switching && lastBlobs.length === rest.length ? lastBlobs : rest;
        measure();
        const dx = oldBox && stageBox ? oldBox.left - stageBox.left : 0;
        const dy = oldBox && stageBox ? oldBox.top - stageBox.top : 0;
        switchFrom = from.map((b) => ({ ...b, cx: b.cx + dx, cy: b.cy + dy }));
        switchTo = rest.map((b) => ({ ...b }));
        switchRoles = rest.map((_, i) => {
          const view = i - off;
          if (view === activeIndexRef.current) return "show";
          if (view === previousIndex) return "hide";
          return "keep";
        });
        switching = true;
        swT.value = 0;
        pSwitch = 0;
        swControl?.stop();
        /* Motion caches one visual element per animated subject and reads the
         * next "from" out of it, so a bare swT.value reset is invisible to it.
         * The explicit [0, 1] keyframes are what make the second and every
         * later switch animate instead of snapping. */
        swControl = run(swT, [0, 1], switchSpring);
        void swControl.finished.then(
          () => {
            if (disposed || currentSwitchId !== switchId) return;
            switching = false;
            if (measureQueued) {
              measureQueued = false;
              measure();
            }
            wake();
          },
          () => undefined,
        );
        switchFrame();
      };
      switchRef.current = doSwitch;

      measure();
      for (const node of collectParts()) node.style.opacity = "0";
      let fontTimer = 0;
      let startTimer = 0;
      let revealed = false;
      const beginReveal = () => {
        if (disposed || revealed) return;
        revealed = true;
        window.clearTimeout(fontTimer);
        measure();
        const barControl = run(bT, 1, barSpring);
        void barControl.finished
          .then(() => {
            if (disposed) return;
            const handOff = () => {
              if (disposed) return;
              revealComplete = true;
              loaded = true;
              if (collectParts().length > 1) setSides(sidesOpenRef.current);
            };
            if (revealDelayRef.current > 0) {
              revealTimer = window.setTimeout(handOff, revealDelayRef.current);
            } else {
              handOff();
            }
          })
          .catch(() => undefined);
        wake();
      };
      /* A late webfont swap re-measures wider text and twitches a bar that
       * already looks settled, so the drop waits for the fonts, capped so a
       * slow font file cannot hold the nav hostage. */
      const waitForFonts = () => {
        const fonts = document.fonts;
        if (fonts && fonts.status !== "loaded") {
          void fonts.ready.then(beginReveal).catch(beginReveal);
          fontTimer = window.setTimeout(beginReveal, 400);
        } else {
          beginReveal();
        }
      };
      if (revealAfter > 0) {
        startTimer = window.setTimeout(waitForFonts, revealAfter);
      } else {
        waitForFonts();
      }

      /* Hovering a part wakes a broad wave at the pointer. The target updates on
       * pointermove while updateHover eases the rendered point toward it. */
      const hoverNodes = collectParts();
      const hoverHandlers = hoverNodes.map((node, index) => {
        const move = (event: PointerEvent) => {
          if (!stageBox) return;
          hoverTarget = {
            x: event.clientX - stageBox.left,
            y: event.clientY - stageBox.top,
          };
          if (!hasHoverPoint) {
            hoverPoint = hoverTarget;
            hasHoverPoint = true;
          }
          wake();
        };
        const enter = (event: PointerEvent) => {
          hoverIndex = index;
          move(event);
        };
        const leave = () => {
          if (hoverIndex === index) hoverIndex = -1;
        };
        node.addEventListener("pointerenter", enter);
        node.addEventListener("pointermove", move);
        node.addEventListener("pointerleave", leave);
        return { node, enter, move, leave };
      });

      const remeasure = () => {
        if (switching) {
          measureQueued = true;
          return;
        }
        if (measure()) wake();
      };
      const observer =
        typeof ResizeObserver === "undefined"
          ? null
          : new ResizeObserver(remeasure);
      observer?.observe(nav);
      if (panelRef.current) observer?.observe(panelRef.current);

      window.addEventListener("resize", remeasure);
      void document.fonts?.ready.then(remeasure).catch(() => undefined);

      const themeObserver =
        typeof MutationObserver === "undefined"
          ? null
          : new MutationObserver(() => {
              field.setColors(readColors(nav, overrides));
              wake();
            });
      themeObserver?.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["data-theme", "class"],
      });

      return () => {
        disposed = true;
        window.clearTimeout(revealTimer);
        window.clearTimeout(fontTimer);
        window.clearTimeout(startTimer);
        for (const control of controls) control.stop();
        switchRef.current = () => {};
        setSidesRef.current = () => {};
        setMenuRef.current = () => {};
        if (panelBodyRef.current) {
          panelBodyRef.current.style.opacity = "";
          panelBodyRef.current.style.transform = "";
        }
        for (const { node, enter, move, leave } of hoverHandlers) {
          node.removeEventListener("pointerenter", enter);
          node.removeEventListener("pointermove", move);
          node.removeEventListener("pointerleave", leave);
        }
        window.removeEventListener("resize", remeasure);
        observer?.disconnect();
        themeObserver?.disconnect();
        gate.dispose();
        cancelAnimationFrame(raf);
        field.dispose();
        fieldRef.current = null;
        for (const node of collectParts()) {
          node.style.transform = "";
          node.style.opacity = "";
        }
      };
    }, [isFluid, revealAfter, collectParts, generation]);

    useFieldRetune(
      fieldRef,
      () => (navRef.current ? readColors(navRef.current, overrides) : null),
      overrides,
      wakeRef,
    );

    /* The loop reads mergeRadius live, but a parked one is holding its last
     * frame and would not show the new merge until the pointer arrived. */
    // biome-ignore lint/correctness/useExhaustiveDependencies: `mergeRadius` is not read here, the loop reads it live
    React.useEffect(() => {
      wakeRef.current?.();
    }, [mergeRadius]);

    const previousViewIndex = React.useRef(activeViewIndex);
    React.useLayoutEffect(() => {
      const previous = previousViewIndex.current;
      previousViewIndex.current = activeViewIndex;
      if (isFluid && previous !== activeViewIndex) {
        switchRef.current(previous);
      }
    }, [activeViewIndex, isFluid]);

    React.useEffect(() => {
      if (isFluid) setSidesRef.current(sidesOpen);
    }, [sidesOpen, isFluid]);

    React.useEffect(() => {
      if (isFluid) setMenuRef.current(menuOpen);
    }, [menuOpen, isFluid]);

    React.useEffect(() => {
      if (!menuOpen) return;
      const onKey = (event: KeyboardEvent) => {
        if (event.key === "Escape") setMenuOpen(false);
      };
      const onPointer = (event: PointerEvent) => {
        const target = event.target as Node | null;
        if (!target) return;
        if (navRef.current?.contains(target)) return;
        setMenuOpen(false);
      };
      document.addEventListener("keydown", onKey);
      document.addEventListener("pointerdown", onPointer);
      return () => {
        document.removeEventListener("keydown", onKey);
        document.removeEventListener("pointerdown", onPointer);
      };
    }, [menuOpen]);

    // biome-ignore lint/correctness/useExhaustiveDependencies: brand/views/satellites gate which nodes exist
    React.useEffect(() => {
      const nodes = [
        brandRef.current,
        ...viewRefs.current.filter((_, i) => i !== activeViewIndex),
        ...Object.values(satRefs.current),
      ];
      for (const node of nodes) {
        if (!node) continue;
        if (sidesOpen) node.removeAttribute("inert");
        else node.setAttribute("inert", "");
      }
    }, [sidesOpen, activeViewIndex, brand, views, satellites]);

    /* A closed panel is still laid out, because the field has to know the shape
     * it is growing toward. Laid out but not reachable: inert, or the whole menu
     * is in the tab order behind a bar that shows no sign of it. */
    // biome-ignore lint/correctness/useExhaustiveDependencies: `collapsed` is what mounts the panel, so the attribute has to be set again on the render that creates it.
    React.useEffect(() => {
      const node = panelRef.current;
      if (!node) return;
      if (menuOpen) node.removeAttribute("inert");
      else node.setAttribute("inert", "");
    }, [menuOpen, collapsed]);

    const part = cn(
      "relative rounded-full",
      !fluidShell &&
        "border border-border bg-surface/85 shadow-raised backdrop-blur-xl",
    );

    const navStyle: React.CSSProperties = {
      ...style,
      ...(offset ? { transform: `translateY(${offset}px)` } : null),
    };

    const brandNode = brand ? (
      <div
        ref={brandRef}
        className={cn(part, !sidesOpen && !fluidShell && "hidden")}
      >
        <Link
          href={brandHref}
          aria-label={brandLabel}
          onClick={() => onNavigate?.(brandHref)}
          className="flex min-h-11 items-center rounded-full px-4 text-sm font-medium text-ink outline-none focus-visible:ring-focus sm:px-5"
        >
          {brand}
        </Link>
      </div>
    ) : null;

    const satelliteNode = (satellite: SulaNavSatellite) => (
      // biome-ignore lint/a11y/useSemanticElements: a fieldset is for form controls; this is a named grouping of nav controls inside a landmark
      <div
        key={satellite.id}
        ref={(node) => {
          satRefs.current[satellite.id] = node;
        }}
        role="group"
        aria-label={satellite.label}
        className={cn(part, !sidesOpen && !fluidShell && "hidden")}
      >
        {satellite.children}
      </div>
    );

    const itemLink = (item: SulaNavItem, inPanel: boolean) => (
      <Link
        href={item.href}
        aria-current={item.href === activeItem ? "page" : undefined}
        onClick={() => {
          onNavigate?.(item.href);
          if (inPanel) setMenuOpen(false);
        }}
        className={cn(
          "flex min-h-11 items-center gap-2 rounded-full text-sm whitespace-nowrap outline-none",
          "text-muted transition-tint duration-fast ease-soft hover:text-ink",
          "aria-[current=page]:text-ink focus-visible:ring-focus",
          inPanel
            ? "w-full gap-3 px-4 aria-[current=page]:bg-ink/6"
            : "px-3 sm:px-4",
        )}
      >
        {item.icon ? (
          <span aria-hidden="true" className="inline-flex shrink-0">
            {item.icon}
          </span>
        ) : null}
        <span
          className={cn(!inPanel && item.icon && LABEL_COLLAPSE[labelsFrom])}
        >
          {item.label}
        </span>
      </Link>
    );

    const menuDroplet = (
      <button
        type="button"
        aria-expanded={menuOpen}
        aria-controls={panelId}
        aria-label={menuLabel}
        onClick={() => setMenuOpen((open) => !open)}
        className="grid size-11 place-items-center rounded-full text-ink outline-none focus-visible:ring-focus"
      >
        <span
          aria-hidden="true"
          className="relative flex h-4 w-5 flex-col justify-between"
        >
          {[0, 1, 2].map((line) => (
            <span
              key={line}
              className={cn(
                "h-px w-full origin-center rounded-full bg-current",
                "transition-layout duration-base ease-spring motion-reduce:transition-none",
                menuOpen && line === 0 && "translate-y-[7.5px] rotate-45",
                menuOpen && line === 1 && "scale-x-0 opacity-0",
                menuOpen && line === 2 && "-translate-y-[7.5px] -rotate-45",
              )}
            />
          ))}
        </span>
      </button>
    );

    const viewNodes = views.map((view, index) => {
      const isActive = index === activeViewIndex;
      const isMenu = isActive && collapsed;
      return (
        <div
          key={view.href}
          ref={(node) => {
            viewRefs.current[index] = node;
          }}
          className={cn(
            part,
            !isActive && !sidesOpen && !fluidShell && "hidden",
          )}
          data-active={isActive || undefined}
        >
          {isMenu ? (
            menuDroplet
          ) : isActive ? (
            <ul className="flex items-center gap-1 p-1.5">
              <span
                aria-hidden="true"
                className={cn(
                  "pointer-events-none absolute top-1.5 bottom-1.5 left-0 rounded-full bg-ink/6",
                  "transition-layout duration-slow ease-spring motion-reduce:transition-none",
                  !indicator.ready && "opacity-0",
                )}
                style={{
                  width: indicator.width,
                  transform: `translateX(${indicator.left}px)`,
                }}
              />
              {activeItems.map((item) => (
                <li
                  key={item.href}
                  ref={(node) => {
                    itemRefs.current[item.href] = node;
                  }}
                  className={cn("relative z-10", item.className)}
                >
                  {itemLink(item, false)}
                </li>
              ))}
            </ul>
          ) : (
            <Link
              href={view.href}
              aria-label={view.label}
              onClick={(event: React.MouseEvent) => {
                if (onViewChange) {
                  event.preventDefault();
                  onViewChange(view.href);
                }
              }}
              className="grid size-11 place-items-center rounded-full text-ink outline-none focus-visible:ring-focus"
            >
              {view.icon}
            </Link>
          )}
        </div>
      );
    });

    const stage = keepCanvas ? (
      <div
        ref={stageRef}
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute overflow-hidden",
          !fluidShell && "hidden",
        )}
        style={{
          top: -DROP_HEIGHT,
          bottom: collapsed ? -PANEL_REACH : -CANVAS_SLACK,
          left: -SLACK_X,
          right: -SLACK_X,
        }}
      >
        <canvas ref={canvasRef} />
      </div>
    ) : null;

    const panelNode = collapsed ? (
      <div
        ref={panelRef}
        id={panelId}
        className={cn(
          "absolute top-full right-0 left-0 z-10 mt-3 max-h-[min(520px,calc(100dvh-5rem))] rounded-[28px] p-2",
          fluidShell
            ? "transition-opacity duration-fast motion-reduce:transition-none"
            : "border border-border bg-surface/95 shadow-raised backdrop-blur-xl",
          menuOpen && "overflow-y-auto",
          !menuOpen &&
            (fluidShell ? "pointer-events-none overflow-hidden" : "hidden"),
        )}
      >
        <div ref={panelBodyRef} className={cn(fluidShell && "opacity-0")}>
          <ul className="flex flex-col gap-0.5">
            {activeItems.map((item) => (
              <li key={item.href}>{itemLink(item, true)}</li>
            ))}
          </ul>
          {hasSatellites ? (
            <div className="mt-2 flex flex-col gap-1 border-border border-t pt-2">
              {[...leftSats, ...rightSats].map((satellite) => (
                // biome-ignore lint/a11y/useSemanticElements: a fieldset is for form controls; this is a named grouping of nav controls inside a landmark
                <div
                  key={satellite.id}
                  role="group"
                  aria-label={satellite.label}
                  className="flex min-h-11 items-center px-2"
                >
                  {satellite.children}
                </div>
              ))}
            </div>
          ) : null}
        </div>
      </div>
    ) : null;

    return (
      <nav
        ref={(node) => {
          navRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        aria-label={ariaLabel}
        data-fluid={fluidShell ? "on" : "off"}
        className={cn(
          "relative items-center",
          /* Satellites need real corners to fly to, and the body needs to stay
           * centred no matter how unbalanced the two ends are, which is what a
           * flex row cannot give. */
          hasSatellites && !collapsed
            ? "grid w-full grid-cols-[1fr_auto_1fr] gap-2 sm:gap-4"
            : collapsed
              ? "flex w-full justify-between gap-2"
              : "flex gap-4",
          collapseBelow && !collapsed && COLLAPSE_GUARD[collapseBelow],
          className,
        )}
        style={navStyle}
        {...props}
      >
        {stage}
        {panelNode}

        {collapsed ? (
          <>
            {brandNode}
            {viewNodes}
          </>
        ) : hasSatellites ? (
          <>
            <div className="flex items-center gap-2 justify-self-start sm:gap-4">
              {brandNode}
              {leftSats.map(satelliteNode)}
            </div>
            <div className="flex items-center gap-4 justify-self-center">
              {viewNodes}
            </div>
            <div className="flex items-center gap-2 justify-self-end sm:gap-4">
              {rightSats.map(satelliteNode)}
            </div>
          </>
        ) : (
          <>
            {brandNode}
            {viewNodes}
          </>
        )}
      </nav>
    );
  },
);
SulaNav.displayName = "SulaNav";