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.
_❯ 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
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, overanimation-range: 0 160px. The home's hero does the same on the page itself,scroll(root block), landed after half a screen.Only the edges move. One keyframe,
inset(0 0)toinset(0 gutter)onclip-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.Pinned while it lands. The frame's stage is
position: stickyand a spacer as tall as the range follows it, so the first scroll tightens the frame before the page moves on.The xray's bar is the same timeline. The gold bar under the page is a second animation on
--lab-morphwith the same range,scaleX(0 → 1): it shows the timeline's progress without a line of script.
Cost
No listener, no measure. The browser advances the animation as it scrolls, on the compositor where it can: no
scrollevent, nogetBoundingClientRect, the same frame before and after hydration. This Lab's slider and readout do listen, to print the numbers; the frame never does.Opt-in. Behind
@supports (animation-timeline: scroll())andprefers-reduced-motion: no-preference: elsewhere the frame stays open, as the home's does.
See also
- mesh-background — the gradient that fills the hero's frame.
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}`,
},
},
},
},
});