Anatomy of the Resizable Header
How one nav bar morphs from a wide rounded surface into a floating pill on scroll, and carries two independent layoutId tracks for hover and active state.
Most "shrinking header" implementations swap two separate DOM trees at a
breakpoint. Resizable Header is one motion.nav the whole time — a single
element whose class names change and whose box framer FLIPs between them,
so the shrink reads as one continuous move instead of a cut.
One nav: logo, links, CTA
Structurally it's a <header> wrapping a single motion.nav — a
shrink-0 logo on the left, a link row in the middle with room for a hover
pill and an active underline, and a shrink-0 CTA plus mobile toggle on the
right:
- Logo
- brand mark, shrink-0 on the left
- Links
- nav row — carries the hover pill + active underline
- CTA
- shrink-0 action slot on the right
<motion.nav
layout
transition={spring}
className={`... border border-border bg-background/70 backdrop-blur-xl ${
shrunk
? "w-full max-w-2xl rounded-full px-3 py-2 shadow-lg"
: "w-full max-w-5xl rounded-2xl px-4 py-3 shadow-sm"
}`}
/>Scroll crosses a threshold, the box morphs
useScroll tracks scrollY; a useMotionValueEvent listener flips a
single boolean the instant it passes scrollThreshold (default 24px).
That boolean is the only thing driving the two class strings above — the
layout prop on motion.nav does the rest, FLIPping the box from
max-w-5xl/rounded-2xl to max-w-2xl/rounded-full on a spring instead
of snapping:
- Expanded
- max-w-5xl · rounded-2xl · scrollY ≤ threshold
- Collapsed
- max-w-2xl · rounded-full pill · past threshold
const { scrollY } = useScroll(scrollRef ? { container: scrollRef } : undefined);
useMotionValueEvent(scrollY, "change", (latest) => {
setShrunk(latest > scrollThreshold);
});
const spring = { type: "spring", stiffness: 320, damping: 32, mass: 0.9 };The same 320/32/mass 0.9 spring as Mega Menu's panel morph — both are
"resize a real box" animations, and both want that extra mass so a wide
surface doesn't overshoot visibly on the way to its new width.
Two layoutId tracks on one link row
The link row carries two independent motion.spans, each bound to its own
layoutId: a hover pill that fills whatever link the pointer is over, and
a thin active underline that only follows a click. They don't know about
each other — the hover pill can sweep past three links while the
underline sits still on the one that's actually active:
- Hover pill
- layoutId span, fills the item under the pointer
- Active underline
- separate layoutId — tracks the clicked link only
{hovered === link.href && (
<motion.span layoutId={`${ids.pill}-hover`} transition={spring} className="... bg-accent" />
)}
{isActive && (
<motion.span layoutId={ids.pill} transition={spring} className="... bg-foreground" />
)}Sticky by default, container-aware, mobile-safe
sticky (default true) pins the header with sticky top-0 z-sticky;
set it false for a header that scrolls away normally. For layouts where
the page itself isn't the scrolling element — a modal body, a split-pane
panel — pass scrollRef and useScroll tracks that container's scroll
position instead of the window's. Below md, the link row and CTA hide
entirely behind a hamburger button that opens an AnimatePresence dropdown
sharing the header's border radius and shadow language.
Accessibility
The active link carries aria-current="page", every link gets a
focus-visible ring offset from the background so it reads clearly
against the blurred surface, and the mobile toggle exposes aria-expanded
so assistive tech knows the drawer's state without relying on the icon
morph alone.
The result
Ship faster with Northwind
Scroll this panel — the nav springs into a compact, blurred pill and the active-link indicator rides along.
Scroll it: one nav, one threshold, one spring — the bar becomes a pill and the two highlight tracks keep riding along underneath it.