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.
↓Scroll to step through it
One nav: logo, links, CTA
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.
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:
Scroll threshold morph
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:
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
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:
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.
The result
Scroll it: one nav, one threshold, one spring — the bar becomes a pill and the two highlight tracks keep riding along underneath it.
- 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.navlayouttransition={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"}`}/>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.
- 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.navlayouttransition={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"}`}/>One nav: logo, links, CTA
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.
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:
- 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 };Scroll threshold morph
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:
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.
- 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" />)}Two layoutId tracks
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:
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.
Ship faster with Northwind
Scroll this panel — the nav springs into a compact, blurred pill and the active-link indicator rides along.
The result
Scroll it: one nav, one threshold, one spring — the bar becomes a pill and the two highlight tracks keep riding along underneath it.
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.
Motion Score
opacityFade / cross-fade