All patterns

Profile Header Parallax

The cover drifts at a fraction of the scroll while the avatar rides up and docks into the title bar.

socialpremiumelegantautomatic · finite · advanced · ~1.7s
Variant

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.

350 lines · react + motion only
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.

Profile headerChannel or community pageCollapsing detail headerCover art page

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Priya RamanTrail running · Gothenburg
    214Posts1,842Followers306Following
    Follow
    Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Profile page

    The profile banner lags behind the timeline and the name settles into the navigation bar.

Related patterns