Profile Header Parallax
The cover drifts at a fraction of the scroll while the avatar rides up and docks into the title bar.
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 } from "react";
import {
animate,
motion,
useReducedMotion,
useScroll,
useTransform,
} from "motion/react";
/**
* Vibary · Profile Header Parallax
*
* The cover drifts at a fraction of the scroll while the profile slides
* over it, and the avatar rides up and shrinks until it docks in the
* title bar — the bar's own title crossfading in as it arrives.
*
* Scroll is read from THIS component's own pane, not from the window, so
* the header works inside a card, a modal or a preview stage. See the
* comment on `useScroll` below for the one-line switch to page scroll.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The cover is a CSS gradient by default — no asset needed; pass
* `coverImage` to use real art. Surfaces are mixed from the inherited
* text color, so the panel reads correctly on light and dark pages.
* Works with zero props; tune via `variant`, `name`, `handle`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ProfileHeaderParallaxProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
name?: string;
handle?: string;
bio?: string;
/** Monogram shown on the avatar disc. */
initials?: string;
/** Optional real cover art. Omitted, the cover is a CSS gradient. */
coverImage?: string;
/** Cover gradient, used whenever `coverImage` is omitted. */
coverGradient?: string;
/** Height of the scrolling pane, in px. */
height?: number;
/** Demo the scroll once on mount. Turn this off in production. */
autoScroll?: boolean;
};
type VariantConfig = {
/** Share of the scroll the cover keeps — 0 is locked to the content. */
drift: number;
/** How much the cover zooms across the scroll. */
zoom: number;
/** Seconds for the one-shot demonstration scroll. */
autoSeconds: number;
};
// No springs here: every value is a projection of the scroll position,
// so the header is wherever the reader left it rather than easing toward
// a target it was told about. Variants differ in how much the cover
// lags — never in how far the avatar travels, since its destination is
// a fixed slot in the bar.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A hint of depth. For a profile that is mostly a content list.
subtle: { drift: 0.24, zoom: 1.01, autoSeconds: 2.2 },
// Clearly slower than the content, still calm. All-purpose.
default: { drift: 0.38, zoom: 1.06, autoSeconds: 1.7 },
// A pronounced lag, for a hero profile on a marketing surface.
playful: { drift: 0.65, zoom: 1.15, autoSeconds: 1.3 },
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` yields surfaces and borders that are correctly toned on
* a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const COVER_HEIGHT = 120;
const BAR_HEIGHT = 46;
const AVATAR_LARGE = 64;
const AVATAR_SMALL = 30;
/** Scroll distance over which the header collapses into the bar. */
const DOCK_AT = 96;
/** Avatar rest position, measured from the top of the pane. */
const AVATAR_TOP = COVER_HEIGHT - 26;
/** Where the avatar ends up: vertically centred in the bar. */
const AVATAR_DOCKED_TOP = (BAR_HEIGHT - AVATAR_SMALL) / 2;
const POSTS: readonly { title: string; meta: string }[] = [
{ title: "Rewrote the onboarding checklist end to end", meta: "2h · 41 replies" },
{ title: "Three things I got wrong about pricing pages", meta: "Yesterday · 128 replies" },
{ title: "A short thread on shipping in small slices", meta: "Mon · 64 replies" },
{ title: "Notes from a week of customer calls", meta: "Sun · 22 replies" },
];
export default function ProfileHeaderParallax({
variant = "default",
name = "Marisol Vega",
handle = "@marisolbuilds",
bio = "Product design at a small team. Writing about interfaces that stay out of the way.",
initials = "MV",
coverImage,
coverGradient = "linear-gradient(135deg, #4C7DF0 0%, #7C5AE8 52%, #B45BC4 100%)",
height = 300,
autoScroll = true,
}: ProfileHeaderParallaxProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const paneRef = useRef<HTMLDivElement>(null);
// The pane is the scroll source. For a full-page profile header, drop
// the `container` option — `useScroll()` then tracks window scroll —
// and remove the height/overflow from the pane below.
const { scrollY } = useScroll({ container: paneRef });
// Reduced motion keeps the docking bar (it is navigation, not
// decoration) and drops the depth effect: no lag, no zoom.
const drift = reduceMotion ? 0 : cfg.drift;
const zoom = reduceMotion ? 1 : cfg.zoom;
const coverY = useTransform(scrollY, [0, 220], [0, 220 * drift]);
const coverScale = useTransform(scrollY, [0, 220], [1, zoom]);
const coverDim = useTransform(scrollY, [0, DOCK_AT], [0, 0.45]);
const avatarSize = useTransform(
scrollY,
[0, DOCK_AT],
[AVATAR_LARGE, AVATAR_SMALL]
);
const avatarY = useTransform(
scrollY,
[0, DOCK_AT],
[0, AVATAR_DOCKED_TOP - AVATAR_TOP]
);
const avatarRing = useTransform(scrollY, [0, DOCK_AT], [3, 2]);
// Two monograms at two fixed sizes, crossfaded — so the glyphs hand off
// rather than being scaled down with the disc that carries them.
const monogramLarge = useTransform(scrollY, [6, DOCK_AT * 0.55], [1, 0]);
const monogramSmall = useTransform(scrollY, [DOCK_AT * 0.6, DOCK_AT], [0, 1]);
const barOpacity = useTransform(scrollY, [DOCK_AT * 0.6, DOCK_AT], [0, 1]);
const barTitleY = useTransform(scrollY, [DOCK_AT * 0.6, DOCK_AT], [7, 0]);
useEffect(() => {
const pane = paneRef.current;
if (!pane || !autoScroll || reduceMotion) return;
pane.scrollTop = 0;
// scrollTop is not a CSS property, so the value is animated on its own
// and written to the pane each frame.
const controls = animate(0, DOCK_AT + 54, {
duration: cfg.autoSeconds,
delay: 0.55,
ease: [0.32, 0.72, 0, 1],
onUpdate: (value) => {
pane.scrollTop = value;
},
});
// The reader always wins: any real input cancels the demonstration.
const stop = () => controls.stop();
pane.addEventListener("wheel", stop, { passive: true });
pane.addEventListener("pointerdown", stop);
pane.addEventListener("touchstart", stop, { passive: true });
return () => {
controls.stop();
pane.removeEventListener("wheel", stop);
pane.removeEventListener("pointerdown", stop);
pane.removeEventListener("touchstart", stop);
};
}, [autoScroll, reduceMotion, cfg.autoSeconds]);
return (
<div
style={{
position: "relative",
width: 330,
height,
overflow: "hidden",
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(4),
}}
>
{/* Docked bar. Opaque so the content passes behind it rather than
through it — Canvas is the page's own background color in both
themes, which is exactly what an overlay needs. */}
<motion.div
aria-hidden
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: BAR_HEIGHT,
zIndex: 3,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 14px 0 56px",
background: "Canvas",
color: "CanvasText",
borderBottom: `1px solid ${tone(10)}`,
opacity: barOpacity,
pointerEvents: "none",
}}
>
<motion.span
style={{ y: barTitleY, fontSize: 13, fontWeight: 650, minWidth: 0 }}
>
{name}
</motion.span>
<motion.span style={{ y: barTitleY, fontSize: 11, opacity: 0.5 }}>
284 posts
</motion.span>
</motion.div>
{/* The avatar is not part of the scrolling content: it rides up with
it and then stops, which is what makes it look caught by the bar. */}
<motion.div
aria-hidden
style={{
position: "absolute",
left: 16,
top: AVATAR_TOP,
zIndex: 4,
width: avatarSize,
height: avatarSize,
y: avatarY,
borderRadius: "50%",
borderStyle: "solid",
borderColor: "Canvas",
borderWidth: avatarRing,
background: "linear-gradient(140deg, #F0A24C, #E0577F)",
color: "#fff",
boxSizing: "border-box",
}}
>
<motion.span
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
fontSize: 21,
fontWeight: 600,
letterSpacing: 0.5,
opacity: monogramLarge,
}}
>
{initials}
</motion.span>
<motion.span
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
fontSize: 11.5,
fontWeight: 600,
letterSpacing: 0.3,
opacity: monogramSmall,
}}
>
{initials}
</motion.span>
</motion.div>
<div
ref={paneRef}
style={{
position: "absolute",
inset: 0,
zIndex: 1,
overflowY: "auto",
overflowX: "hidden",
// Vertical gesture belongs to this pane, not to the page behind it.
touchAction: "pan-y",
}}
>
<div
style={{ position: "relative", height: COVER_HEIGHT, overflow: "hidden" }}
>
{/* Oversized on purpose: the layer drifts down as the pane
scrolls up, and the extra height keeps its edge off-screen. */}
<motion.div
style={{
position: "absolute",
top: -52,
left: 0,
right: 0,
height: COVER_HEIGHT + 104,
y: coverY,
scale: coverScale,
background: coverImage ? undefined : coverGradient,
backgroundImage: coverImage ? `url(${coverImage})` : undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
{/* The cover recedes as the bar takes over, so the two never
compete for the same strip of screen. */}
<motion.div
style={{
position: "absolute",
inset: 0,
background: "#000",
opacity: coverDim,
}}
/>
</div>
<div style={{ padding: "44px 16px 20px" }}>
<div style={{ fontSize: 17, fontWeight: 650, lineHeight: 1.25 }}>
{name}
</div>
<div style={{ fontSize: 12.5, opacity: 0.5, marginTop: 2 }}>{handle}</div>
<p style={{ margin: "10px 0 0", fontSize: 12.5, lineHeight: 1.55, opacity: 0.78 }}>
{bio}
</p>
<div style={{ display: "flex", gap: 14, marginTop: 12, fontSize: 12 }}>
<span>
<strong style={{ fontWeight: 650 }}>1,204</strong>
<span style={{ opacity: 0.5 }}> following</span>
</span>
<span>
<strong style={{ fontWeight: 650 }}>8,930</strong>
<span style={{ opacity: 0.5 }}> followers</span>
</span>
</div>
</div>
<div style={{ padding: "0 16px 20px" }}>
{POSTS.map((post) => (
<div
key={post.title}
style={{
padding: "12px 0",
borderTop: `1px solid ${tone(9)}`,
}}
>
<div style={{ fontSize: 13, lineHeight: 1.4 }}>{post.title}</div>
<div style={{ fontSize: 11, opacity: 0.45, marginTop: 4 }}>
{post.meta}
</div>
</div>
))}
</div>
</div>
</div>
);
}About this pattern
Depth is the point: the cover keeps a share of the scroll instead of leaving with it, so the profile reads as content sliding over art rather than one flat sheet moving. Every value — the cover's lag and zoom, the avatar's size and travel, the bar's fade — is a projection of the scroll position, which means the header is always exactly where the reader left it, with nothing easing toward a target it was told about after the fact. The avatar hands its monogram between two fixed type sizes as it shrinks, so no glyph is ever scaled by the disc that carries it, and the bar it lands in is opaque, so content passes behind rather than through. Scroll is read from the component's own pane rather than the window, which is what lets the same header work inside a card, a modal or a preview stage.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Profile page
The profile banner lags behind the timeline and the name settles into the navigation bar.
Related patterns
- Sticky Header CondenseThe display title scrolls away and a compact bar takes over, gaining its surface and rule on the way.
- Story Ring ProgressA segmented ring traces around the avatar, one arc per clip, then goes quiet.
- Account SwitchThe chosen avatar travels up into the header slot while the one it replaces goes back down into the list.