chosen · flow

Flow

Labels are pinned, every pill keeps its exact height, and nothing scales. Each neck’s waist is a smooth function of its distance from the cursor, spring-eased, so a thickening travels along the bar with the pointer like mercury gathering under glass. Off the bar it settles around the selected item.

sweep across it · it should never catch

Production-ready

The same thing with the plumbing: real <a> links · keyboard accessible (Tab + Enter) · active state driven by the URL hash · respects reduced-motion · re-measures on resize · falls back to a tappable menu on narrow screens.

try the keyboard: Tab into it, Enter to navigate

The others, for reference

The alternatives that got us here. All carry the same hit areas and the same fixed labels; only what deforms is different. Turn on show height rule to confirm none of them grow.

a

Neck swell

Per-item rather than continuous: the two necks beside the active item thicken from a thin waist to a full-bodied join. Same deformation as flow, quantised.

c

Pinch

The inverse. The necks beside the active item thin down to threads, so the item you’re pointing at almost breaks free of the bar.

d

Width only

The active pill stretches sideways around its fixed label and the necks shorten to pay for it. The cells scale — horizontally.

original

Concept

The first pass, kept as the before picture: hover spreads the necks, the bar re-centres, labels slide under the cursor, and the necks between the pills aren’t clickable at all.

About this concept

An organic navigation where the menu items are joined into a single continuous shape. Hovering (or focusing) an item makes the whole bar respond, so it feels like one living, elastic object rather than a row of separate buttons.

Built for the Holos app · Holos teal on near-black · Montserrat UI / Fraunces headings.

True vector geometry (not a blur filter)

Early drafts used an SVG “gooey” blur filter to fuse the pills, but that reads soft and raster-y. This version generates one real SVG path every frame: each pill is a flat-topped stadium, and each neck is a pair of cubic Bézier curves that pinch to a waist and meet the pills with horizontal tangents (so the joins are perfectly smooth).

The neck maths came from a hand-drawn reference. With R = cap radius (= half the bar height) and s = half the gap between two pills: the waist pinches to R/3, the pill-side Bézier handle is ⅔·s and the waist-side handle is s − R.

The path builder generalises that: each pill carries its own cap radius, each neck its own waist thickness and waist position, and s is measured separately on each side of the waist. Set the radii equal, the waists to R/3 and the positions to the midpoints and it draws exactly the original shape — every mode below is a different way of feeding that one function.

Flow

The chosen mode. Nothing scales and no label moves: the layout is built from fixed centres rather than accumulated gaps, so there is no re-centring shift at all. The only thing that changes is the waist of each neck, driven by a distance field:

waist[i] = W3 · (1 + FLOW · smoothstep((RADIUS − |cursorX − neckMid[i]|) / RADIUS))

Because that target moves continuously with the cursor, a fixed-duration tween is the wrong tool — it would restart every frame. Flow uses an exponential spring instead, framerate-independent:

k = 1 − exp(−dt / TAU)
cur += (target − cur) · k

When the pointer leaves, the field’s focus point falls back to the selected item’s centre and the same spring carries it there, so the bar settles into a resting swell that marks the selection.

Making it flow

The first build stuttered — the pointer would catch and stick. Four causes, all of them structural rather than incidental:

1 · Uncoalesced input doing layout work

The old mousemove handler called svg.getScreenCTM() to convert the cursor into user space. That is a geometry read, so it forces a synchronous style + layout flush of the whole document — and it ran once per pointer event. Measured on this page: a DOM write followed by getScreenCTM() costs 0.030ms against 0.003ms for the write alone. A 1000Hz mouse therefore asks for a thousand full layout flushes a second, and the event queue backs up faster than frames can drain it. That is exactly what “the mouse gets stuck” feels like.

Now the handler does one thing: stash clientX/clientY and set a dirty flag. Every derived value — hit testing, glow position, label proximity, the flow field — is computed once per animation frame from the most recent sample. Extra events between frames cost nothing.

2 · The matrix is cached

The inverse CTM only changes when the page scrolls, resizes or re-measures, so it is computed once and invalidated on those events. Per-frame conversion is then six multiplies against cached numbers, with no layout flush and no SVGPoint allocation.

3 · The clip path is gone

The glow used to be two large gradient-filled circles inside a <g clip-path> that referenced the shape. Clipping against a path that changes every frame is the most expensive thing the old version did — the clip has to be re-rasterised as a mask each time. It is now a second copy of the same path filled with a userSpaceOnUse radial gradient whose centre follows the focus point. No clip, no mask, and the geometry string is written to both paths from one build.

4 · Fewer, smaller writes

Turn on show frame time to watch it. The loop stops entirely once the spring settles, so an idle bar costs zero.

The hover fix

In the original, hovering an item moves it, and the necks between the pills have no hit area at all — so the cursor drops into a dead gap, the bar collapses back, the cursor is over the item again, and it oscillates. Fixed labels remove the first half; bigHit removes the second.

The other modes

ModePillsNecksHeight
'flow'untouchedwaist thickness = f(distance to cursor)constant
'neck' (A)untouchedwaist thickens beside the active itemconstant
'pinch' (C)untouchedwaist thins to a thread beside the active itemconstant
'width' (D)active pill widensshorten and thicken slightlyconstant
'swell'active pill widens and grows tallershorten and thickengrows
'spread'slide apart, bar re-centresstretchconstant

Flow, neck and pinch are the same one-line change — cfg.thick, the waist multiplier. Positive fills the join in, negative pinches it out, and flow drives the same number from a distance field instead of a per-item flag.

Sliding the waist position toward the active item (cfg.drift, still supported) was tried and dropped. The waist has to fall R − W3 = 16px, so it needs roughly 20px of span on each side to do it gracefully; the resting half-span is only 32.5px, leaving about 12px of usable drift — not enough to see. Push further and the near-side taper collapses into a hard vertical corner. It would work at a resting gap of 1.8·H or more.

Editing

Everything is in one self-contained file. Global constants sit at the top of OrganicNav(); the per-mode numbers live in the ONAV_CFG table just above it.

ConstantDefaultControls
H48Pill height. Drives cap radius R = H/2 and the entire waist proportion.
PAD2.4Horizontal padding inside each pill (space around the text).
W3R/3Resting waist half-thickness. Every neck effect is a multiple of this.
cfg.flow1.35Peak waist thickening directly under the cursor.
cfg.radius190How far along the bar the thickening reaches. Larger = looser, more liquid.
TAU95Spring time constant (ms) for the flow field. Lower = snappier, higher = more syrupy.
FTAU / OTAU70 / 110Springs for the glow’s focus point and for label brightness. The focus snaps while hovering and eases only on exit.
DUR650Ease-in-out tween duration (ms) for the per-item modes. 0 under reduced-motion.
cfg.gap1.35–1.5 ·HResting neck length. The width modes need the larger gap so necks have room to shorten.
cfg.thick+1.35 / −0.70Waist thickness beside the active item, as a multiple of W3.
cfg.drift0How far the waist slides toward the active item. Keep under ~12 at the default gap.
cfg.swell / cfg.vswell16 / 0How far the active pill grows sideways / how much its cap radius grows.
BASE_OP0.72Resting text opacity. Brightens toward 1 near the cursor.
NEAR / FAR70 / 200Cursor distance for full / zero text brightening.
GLOW_R200Radius of the glow gradient, in user units.
UL_MAX40Max width of the selected-item underline.
HIT_OUT / HYST20 / 26Hit-band overshoot past the end caps; sticky margin before hover hands over.
PADV8 — 14 with bigHitVertical breathing room, and the vertical reach of the hit bands.
PREC1Decimal places in the path string. 1 is plenty at this size; 0 is visibly steppy.

Colours

Swap the brand colours in the :root block at the top of the stylesheet:

--bg:    #011012;   /* page background    */
--shape: #031d22;   /* the connected pill shape */
--ink:   #fffcfb;   /* label text         */
--teal:  #279DAF;   /* selected text + underline + glow */

Glow strength lives on the gradient stop-opacities (0.38 / 0.15 / 0) plus the .glow resting opacity (0.42, going to 1 on hover).

Implementing

The component API

OrganicNav(hostElement, {
  id: 'prod',                 // unique prefix for this instance's SVG ids
  items: [                    // your menu
    { label: 'Directory', href: '/directory' },
    { label: 'Map',       href: '/map' },
    /* ... */
  ],
  selectedIndex: 0,           // initial active item
  mode: 'flow',               // 'flow' | 'neck' | 'pinch' | 'width' | 'swell' | 'spread'
  bigHit: true,               // tiled, full-height, latching hit areas
  asLinks: true,              // real <a> links + keyboard + URL routing
  responsive: true            // resize re-measure + mobile fallback menu
});

You can place more than one on a page — just give each a unique id (it namespaces the SVG gradient IDs so instances don’t collide). Each instance only runs a frame loop while it is actually animating.

In Webflow

Real (multi-page) routing

The production demo derives the active item from location.hash. For a normal multi-page site, point each item’s href at a real URL and match on the path instead — change indexFromHash() to compare location.pathname against item.href.

Accessibility notes