Sticky Header Condense
The display title scrolls away and a compact bar takes over, gaining its surface and rule on the way.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState, type UIEvent } from "react";
import { animate, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Sticky Header Condense
*
* The large screen title scrolls away and a compact bar takes over:
* the bar loses height, gains a surface and a rule, and the small title
* rises into it. Two titles at two fixed sizes — never one title being
* shrunk.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the header reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `title`.
* Scroll the panel — or let it demonstrate itself on mount.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StickyHeaderCondenseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Screen title. Rendered twice: large in the content, small in the bar. */
title?: string;
/**
* Scrolls the panel once on mount so the condense is visible without
* input. Turn this off in a real app — there the page scroll is the
* trigger.
*/
demoScroll?: boolean;
/** Fires on each crossing of the condense threshold. */
onCondensedChange?: (condensed: boolean) => void;
};
type VariantConfig = {
/** Seconds for the bar to lose or regain its height. */
collapse: number;
/** Seconds for the compact title and the rule to fade. */
chromeFade: number;
/** How far the compact title rises into the bar, in px. */
titleLift: number;
};
// Quality rule: nothing here springs. The bar's height is a real size
// change, and a spring on height overshoots — which would bounce the rule
// underneath it and drag the title with it. Short eased tweens land once.
// Variants change the pace and the size of the title's rise, never the
// two type sizes, which stay fixed so no glyph is ever scaled.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a straight cut. For dense app chrome that condenses on every
// small scroll.
subtle: { collapse: 0.13, chromeFade: 0.1, titleLift: 3 },
// The all-purpose setting: the bar closes and the title arrives just
// behind it.
default: { collapse: 0.22, chromeFade: 0.16, titleLift: 7 },
// A longer close and more travel on the title — for a content screen
// with a genuinely large display heading.
playful: { collapse: 0.31, chromeFade: 0.22, titleLift: 11 },
};
/** Bar height before and after the condense. Fixed across variants: this
* is layout, and layout shouldn't change character with the motion. */
const EXPANDED_BAR = 74;
const CONDENSED_BAR = 46;
/** Hysteresis, not a single line: a lone threshold makes the bar flicker
* when a trackpad hovers on the boundary. It condenses later than it
* expands. */
const CONDENSE_AT = 44;
const EXPAND_AT = 20;
/** How far the mount-time demo scroll travels, in px. */
const DEMO_SCROLL = 150;
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const REPORTS = [
["Q3 revenue summary", "Finance · 12 min ago"],
["Churn by plan tier", "Analytics · 1 h ago"],
["Support backlog", "Operations · 3 h ago"],
["Seat usage by team", "Billing · Yesterday"],
["Onboarding funnel", "Growth · Yesterday"],
["Invoice exceptions", "Finance · 2 days ago"],
["Data retention audit", "Security · 4 days ago"],
["Regional load test", "Platform · Last week"],
] as const;
export default function StickyHeaderCondense({
variant = "default",
title = "Field Reports",
demoScroll = true,
onCondensedChange,
}: StickyHeaderCondenseProps) {
const [condensed, setCondensed] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const top = event.currentTarget.scrollTop;
const next = condensed ? top > EXPAND_AT : top > CONDENSE_AT;
if (next === condensed) return;
setCondensed(next);
onCondensedChange?.(next);
};
// The preview has no one to scroll it, so the panel scrolls itself once
// and then gets out of the way: the first wheel or touch stops the
// animation mid-flight rather than fighting the reader for the surface.
useEffect(() => {
const element = scrollRef.current;
if (!element || !demoScroll) return;
if (reduceMotion) {
const timer = window.setTimeout(() => {
element.scrollTop = DEMO_SCROLL;
}, 240);
return () => window.clearTimeout(timer);
}
const controls = animate(0, DEMO_SCROLL, {
duration: 1.4,
delay: 0.45,
ease: "easeInOut",
onUpdate: (value) => {
element.scrollTop = value;
},
});
const stop = () => controls.stop();
element.addEventListener("wheel", stop, { passive: true });
element.addEventListener("touchstart", stop, { passive: true });
return () => {
controls.stop();
element.removeEventListener("wheel", stop);
element.removeEventListener("touchstart", stop);
};
}, [demoScroll, reduceMotion]);
// Reduced motion: the bar still condenses — the compact title is the
// information — it simply arrives at its new size instantly.
const collapse = reduceMotion
? { duration: 0 }
: { duration: cfg.collapse, ease: "easeOut" as const };
const chrome = { duration: reduceMotion ? 0.1 : cfg.chromeFade, ease: "easeOut" as const };
const titleLift = reduceMotion ? 0 : cfg.titleLift;
return (
<div
style={{
position: "relative",
width: 336,
height: 356,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
{/* The bar is absolute against this panel, not the viewport, so the
pattern drops into a card or a preview unchanged. For a real
screen, make this `position: sticky; top: 0` inside the scrolling
document (or `fixed` if the header floats above the whole app)
and drive `condensed` from the window's scroll position. */}
<motion.header
initial={false}
animate={{ height: condensed ? CONDENSED_BAR : EXPANDED_BAR }}
transition={collapse}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 2,
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 14px",
}}
>
{/* Surface, rule and blur are one fading layer rather than three
properties on the bar. The blur has to ride the same opacity:
left on the bar itself it would smear the content passing
underneath during the scroll before the condense. */}
<motion.div
aria-hidden
initial={false}
animate={{ opacity: condensed ? 1 : 0 }}
transition={chrome}
style={{
position: "absolute",
inset: 0,
background: tone(8),
borderBottom: `1px solid ${tone(12)}`,
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
pointerEvents: "none",
}}
/>
<button
type="button"
aria-label="Back"
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 9,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
cursor: "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M9.8 3.6 5.4 8l4.4 4.4" />
</svg>
</button>
{/* The compact title. A second element at its own fixed size, not
the large one scaled down — scaling type is the one thing a
condensing header must never do. */}
<motion.span
initial={false}
animate={{
opacity: condensed ? 1 : 0,
y: condensed ? 0 : titleLift,
}}
transition={chrome}
style={{
position: "relative",
flex: 1,
minWidth: 0,
fontSize: 13.5,
fontWeight: 650,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{title}
</motion.span>
<button
type="button"
aria-label="Filter reports"
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 9,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
cursor: "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
aria-hidden
>
<path d="M2.6 4.2h10.8M4.6 8h6.8M6.6 11.8h2.8" />
</svg>
</button>
</motion.header>
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
height: "100%",
overflowY: "auto",
padding: "0 16px 18px",
// Room for the bar at its full height, so nothing starts life
// hidden behind it.
paddingTop: EXPANDED_BAR,
}}
>
<h2
style={{
margin: "6px 0 0",
fontSize: 25,
fontWeight: 680,
letterSpacing: "-0.02em",
lineHeight: 1.15,
}}
>
{title}
</h2>
<p style={{ margin: "6px 0 14px", fontSize: 12.5, opacity: 0.55 }}>
8 reports · updated weekly
</p>
{REPORTS.map(([name, meta]) => (
<div
key={name}
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "10px 0",
borderTop: `1px solid ${tone(10)}`,
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
background: tone(9),
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 2.4h5l3 3v8.2H4z" />
<path d="M9 2.4v3.1h3" />
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 13,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{name}
</span>
<span style={{ display: "block", fontSize: 11.5, opacity: 0.5 }}>
{meta}
</span>
</span>
</div>
))}
</div>
</div>
);
}About this pattern
The header treatment that gives a content screen its full height back. As the reader scrolls past the display title, the bar loses height, fades in a surface and a hairline rule, and the compact title rises into the space the back button leaves. The mechanism that matters is what is not happening: the two titles are separate elements at fixed sizes that cross over, never one heading being scaled down, because scaled type reads blurry mid-flight and cheap at the end. Height is a genuine size change here, so it runs as a short eased tween rather than a spring — a spring would overshoot the bar past its own resting height and take the rule with it. The threshold has hysteresis, which is what stops a trackpad hovering on the boundary from flickering the bar open and shut.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
The oversized screen title gives way to a compact bar title as the list scrolls under it.
Related patterns
- Profile Header ParallaxThe cover drifts at a fraction of the scroll while the avatar rides up and docks into the title bar.
- Expandable Detail CardA compact card that expands in place into a detail view — image and title travel continuously.
- Gallery Lightbox OpenThe pressed thumbnail flies out of the grid into the large view while the backdrop dims behind it.