Morphing Nav Indicator
A nav landmark whose active indicator is one DOM-measured capsule that travels and reshapes to fit any label, driven by a spring you can tune.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/morphing-nav-indicator.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MorphingNavIndicator" component. No
animation library: the spring is integrated by hand on a rAF loop.
Contract
- export const MorphingNavIndicator = forwardRef<HTMLElement, Props>(...) —
renders a <nav>, merges className via cn(), spreads the rest onto the root.
- items: { id: string; label: string; href?: string; icon?: ReactNode;
disabled?: boolean }[]. An item with href renders a real <a>; otherwise a
<button type="button">. A disabled item is always the button form (an anchor
cannot be disabled) and is not focusable.
- activeId?: string (controlled) / defaultActiveId?: string (uncontrolled,
defaults to items[0].id) / onActiveChange?: (id) => void — fires in both
modes. currentId = activeId ?? internalId.
- orientation?: "horizontal" | "vertical" = "horizontal".
- shape?: "pill" | "underline" = "pill".
- stiffness = 420, damping = 38 (mass 1; 2*sqrt(stiffness) is critical damping).
- label = "Sections" — the accessible name of the nav landmark.
Behavior
- Measurement, not guesswork: the active item's offsetLeft / offsetTop /
offsetWidth / offsetHeight are read against the track (a position:relative
wrapper that is both the items' offsetParent and the indicator's containing
block), so the target box needs no getBoundingClientRect and no scroll math.
- One spring per box channel (x, y, w, h) integrated in a rAF loop with fixed
1/240s sub-steps (a stiff spring integrated with a single 50ms frame delta
explodes). Position and velocity live in refs; each frame writes
transform / width / height straight to the indicator node, so a travelling
pill never re-renders the item tree. The loop stops when every channel is
within 0.25px and 2px/s of target, and snaps exactly onto it.
- Squash & stretch: stretch = min(|velocity along the axis| / 6000, 0.12); the
box grows along the travel axis by that factor and thins across it (pill
only). This is what reads as one liquid object rather than a moved rectangle.
- shape="pill": the capsule is opaque and paints *above* the labels, so a second
copy of the whole item row (aria-hidden, pointer-events-none, text in
primary-foreground) is clipped with clip-path: inset(...) to the exact same
stretched box — letters flip colour precisely at the capsule's edge as it
sweeps past. Clip insets are clamped at 0 so an over-stretched box never
produces an invalid clip-path.
- shape="underline": no duplicate layer; a 2px rule rides the bottom (or left,
when vertical) edge, and the box is stretched out to the track edge so the
rule lands on the nav's own border instead of floating above it. The active
label recolours with a plain CSS transition.
- Re-measure triggers: a ResizeObserver on the track *and* on every item (a font
swap changes label widths without resizing the container), plus
document.fonts.ready. Both funnel through one rAF-coalesced schedule() so a
burst of callbacks costs one measurement. The observer is disconnected and
both rAF handles are cancelled on unmount.
- prefers-reduced-motion, read through a matchMedia listener that is kept
attached (a mid-session flip takes effect) and removed on unmount: the box is
assigned rather than integrated, so selection is instant and complete.
- SSR: no window/document during render; the indicator is opacity-0 until the
first measurement, so the server output is just legible labels — the nav is
never dependent on an animation having run.
- Pointer reality: there is no pointer tracking at all. Nothing is attached to
window/document, nothing calls preventDefault, and a horizontal bar that
outgrows its parent scrolls natively, so touch is untouched on coarse
pointers; hover affordances are already gated by Tailwind's
@media (hover: hover).
- Modified clicks belong to the browser: on a link item the click handler bails
out on defaultPrevented, meta / ctrl / shift / alt or a non-primary button, so
⌘-clicking an item opens it in a new tab while this document — and therefore
the indicator and aria-current — stays on the destination the user is actually
looking at. The button branch has no such default to respect.
- Accessibility: <nav aria-label>, aria-current="page" on the active link (or
"true" on the active button), both decorative layers aria-hidden and
pointer-events-none so clicks land on the real control underneath, a
focus-visible ring with ring-offset-2 (it sits outside the capsule, so a
focused item under the pill still shows its ring), and an sr-only
role="status" aria-live="polite" region announcing "<label> selected". That
region is driven off the resolved currentId in an effect that compares against
the last announced id — not off the click — so a controlled parent moving
activeId from outside the nav is announced too, a parent that declines the
change announces nothing, and mounting is silent.
- Keyboard model is plain Tab order — these are navigation destinations, not a
composite widget, so no roving tabindex and no arrow-key hijacking.
Rendering & styling
- Root <nav>: inline-flex max-w-full; pill shape adds border bg-card/70
shadow-sm backdrop-blur-sm with rounded-full (horizontal) / rounded-3xl
(vertical); underline shape adds border-b (or border-l).
- Track: relative flex gap-1 p-1.5, flex-col items-start when vertical (items
hug their labels so the capsule morphs on both axes).
- Items and their clipped duplicates share one layout class string — identical
padding, gap, font-size and font-weight — otherwise the two layers drift.
Font weight never changes with selection, so selecting never reflows.
- Indicator body: bg-primary; behind it a blurred halo using
linear-gradient(color-mix(in oklab, var(--chart-1) 80%, transparent),
color-mix(in oklab, var(--chart-4) 80%, transparent)) — decorative colour
lives outside the capsule where no text sits, so contrast is unaffected.
- Semantic tokens only (bg-primary, text-primary-foreground, bg-card,
text-muted-foreground, border, ring, var(--chart-1/4)); no hex, rgb or oklch.
Customization levers
- Feel: stiffness/damping are the only knobs that matter — 220/26 is lazy and
wide, 420/38 (default) is confident, 700/52 is near-instant. Keep damping
near 2*sqrt(stiffness) for no overshoot, drop ~10% below it for a bounce.
- Liquidity: the /6000 divisor and the 0.12 clamp control squash & stretch —
set the clamp to 0 for a rigid capsule, raise to ~0.2 for a rubber-band feel.
- Shape: pill for marketing / floating bars, underline for docs and app chrome.
For a shorter underline, inset the indicator box by a few px on each side
during measurement.
- Density: px-4 py-2 → px-3 py-1.5 for a compact bar; gap-1 → gap-0 to let the
capsule edges touch; p-1.5 on the track is the focus-ring gutter, so keep it
at least 4px if you keep ring-offset-2.
- Vertical rail: swap items-start for items-stretch and the capsule stops
changing width — a classic sidebar. Add a fixed width on the root to pin it.
- Colour: the capsule is bg-primary; swap in bg-foreground for a neutral bar,
or bg-accent + text-accent-foreground for a softer one (change the duplicate
layer's text colour to match). The halo hue is pure decoration — retint it
with any two --chart-* tokens, or delete that span entirely.
- Routing: replace the <a> with your router's Link (next/link, react-router)
and keep onActiveChange for the indicator; if the active section comes from
usePathname(), pass it as activeId and the component stays fully controlled.Concepts
- DOM-measured target — the indicator's destination is whatever the active item actually measures right now, taken from offsets against its own offsetParent; label lengths, icons, i18n copy and font swaps all just work, and no width is ever hard-coded.
- Spring integrator — position and velocity are carried in refs and advanced in fixed sub-steps, so interrupting a travel mid-flight continues from the current speed instead of restarting; distance never changes the feel, only stiffness and damping do.
- Squash and stretch — the box elongates along its travel axis in proportion to velocity and thins across it, which is the difference between "one liquid shape" and "a rectangle being translated".
- Clipped label layer — an aria-hidden duplicate of the item row is clipped to the same box as the capsule, so letters flip to the inverted colour exactly at its edge while it sweeps, and no label is ever unreadable mid-travel.
- Remeasure triggers — a ResizeObserver watches the track and each item (a font swap resizes labels without resizing the container) and
document.fonts.readycovers the swap itself; every callback funnels into one rAF-coalesced measurement. - Reduced-motion assignment — with motion off the box is assigned instead of integrated: the selection is instantly correct and complete, never stuck waiting on an animation that will not run.
Scroll Restoration
Remembers where every scroll container was when a history entry was left and puts it back on Back — including the inner panes the browser never restores.
Sticky CTA Bar
A conversion bar that arrives once a sentinel element — usually the hero's own CTA — leaves the viewport, docks to the bottom or top edge, and stays dismissed for the rest of the session.