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, with 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:
It uses the same 320/32/mass 0.9 spring as Mega Menu's panel morph. Both
resize a real box, and both need 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 are independent:
the hover pill can sweep past three links while the underline sits still
on the active one:
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. With one nav, one threshold and 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, with 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:
It uses the same 320/32/mass 0.9 spring as Mega Menu's panel morph. Both
resize a real box, and both need 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 are independent:
the hover pill can sweep past three links while the underline sits still
on the active one:
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. With one nav, one threshold and 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