Navigation

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface MorphingNavItem {
  /** Stable id — this is what `onActiveChange` reports and what `activeId` matches. */
  id: string
  label: string
  /** Renders a real `<a>`; omit it and the item renders a `<button type="button">`. */
  href?: string
  /** Optional leading icon; sized by the item's own `[&_svg]:size-4` rule. */
  icon?: React.ReactNode
  /** Rendered but not activatable — never focusable, and always a button (an `<a>` cannot be disabled). */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/morphing-nav-indicator.json

Prompt

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.ready covers 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.

On This Page