All patterns

Sticky Header Condense

The display title scrolls away and a compact bar takes over, gaining its surface and rule on the way.

navigationelegantpremiumautomatic · finite · intermediate · ~0.3s
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.

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

Content screen headerMobile detail viewDocumentation page chromeProfile screen title

Where it shows up

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

  • 10:15
    Home
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmedDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    HomeSearchActivityProfile
    Mobile navigation

    The oversized screen title gives way to a compact bar title as the list scrolls under it.

Related patterns