Scroll Morph

The hero's frame closing in on the content column as the page scrolls — one clip-path keyframe on a scroll timeline, no scroll listener.

CSSscroll-driven animationsanimation-timelineclip-pathstickyScroll · 3 files · 414 lines
CSS

_❯ scroll this page

The sides close in on the column.

No scroll-driven animations here (or reduced motion is on): the frame stays open, as the home's does.

animation-timeline: --lab-morph · animation-range: 0 160px · inset(0 → 28px)

man scroll-morph

A line ref opens it in the source below

Name

scroll-morph — a frame that tightens onto the page's column as it scrolls, in CSS alone.

How it works

  1. The scroll is the clock. The scroller names a timeline (scroll-timeline: --lab-morph block); the frame's animation reads it (animation-timeline: --lab-morph) instead of time, over animation-range: 0 160px. The home's hero does the same on the page itself, scroll(root block), landed after half a screen.

  2. Only the edges move. One keyframe, inset(0 0) to inset(0 gutter) on clip-path: the sides close in on the content column and nothing scales, so the text inside never reflows. linear, because the hand on the scroll is the easing.

  3. Pinned while it lands. The frame's stage is position: sticky and a spacer as tall as the range follows it, so the first scroll tightens the frame before the page moves on.

  4. The xray's bar is the same timeline. The gold bar under the page is a second animation on --lab-morph with the same range, scaleX(0 → 1): it shows the timeline's progress without a line of script.

Cost

  1. No listener, no measure. The browser advances the animation as it scrolls, on the compositor where it can: no scroll event, no getBoundingClientRect, the same frame before and after hydration. This Lab's slider and readout do listen, to print the numbers; the frame never does.

  2. Opt-in. Behind @supports (animation-timeline: scroll()) and prefers-reduced-motion: no-preference: elsewhere the frame stays open, as the home's does.

See also

import { createVar, keyframes, style } from "@vanilla-extract/css";
import { vars } from "@/styles/theme.css";

/** How far each side closes in, the scroll it takes, and the frame's radius (set from the controls). */
export const morphGutter = createVar();
export const morphRange = createVar();
export const morphRadius = createVar();

/** Where scroll-driven animations run. Elsewhere the frame simply stays open, as the hero's does. */
export const morphSupports = "(animation-timeline: scroll())";
export const morphMotion = "(prefers-reduced-motion: no-preference)";

// The hero's move (app/pages/welcome/…/welcome-hero-scroll-morph.css.ts): only the frame's sides
// travel, onto the content column. Nothing scales, so the text inside never reflows.
const frameTighten = keyframes({
  from: { clipPath: `inset(0 0 round ${morphRadius})` },
  to: { clipPath: `inset(0 ${morphGutter} round ${morphRadius})` },
});

// The page in miniature: its own scroller names the timeline every piece below reads.
export const scroller = style({
  position: "relative",
  height: "24rem",
  overflowY: "auto",
  overscrollBehavior: "contain",
  scrollTimeline: "--lab-morph block",
  borderRadius: vars.radius.md,
  backgroundColor: vars.colors.background,
});

// Pinned while the frame lands, like the hero's stage.
export const pin = style({
  position: "sticky",
  top: 0,
  zIndex: 1,
  padding: vars.spacing.sm,
});

export const frame = style({
  position: "relative",
  height: "15rem",
  overflow: "hidden",
  clipPath: `inset(0 0 round ${morphRadius})`,
  "@supports": {
    [morphSupports]: {
      "@media": {
        [morphMotion]: {
          animationName: frameTighten,
          // Linear: the scroll is the easing.
          animationTimingFunction: "linear",
          animationFillMode: "both",
          animationTimeline: "--lab-morph",
          animationRange: `0 ${morphRange}`,
        },
      },
    },
  },
});
scroll-morph.css.ts · 58 lines · 1.9K · ts_Source_