Shaped Section Boundary — the edge between stacked sections is a shape, opened by scroll
Goal
Build a scrolling page of full-height, flat-colour sections where the top edge of each section is a shape, not a straight line — a wave, a chevron, a diagonal, a torn edge, an arch — and where the amplitude of that shape is written by the scroll position: the edge starts flat as the section appears from the bottom of the viewport and reaches its full form by the time the section has risen a little past half the screen.
The point is not decoration. Stacked sections of flat colour with straight butt joins are the single most recognisable tell of a generated page. Giving the join a shape — and animating that shape — costs one element and one ScrollTrigger per section.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework, no SVG, no canvas — the shapes are pure CSS clip-path.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Wire Lenis to ScrollTrigger — this is not optional:
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
Lenis animates its own scroll position; ScrollTrigger reads the native one. Without that first line the two clocks drift and the triggers fire at the wrong moment or not at all — the component looks broken in a way that never reaches the console. lagSmoothing(0) stops GSAP from swallowing a long frame, which on a scrubbed mechanic shows up as a jump.
Register once with gsap.registerPlugin(ScrollTrigger) and wrap setup in DOMContentLoaded.
The three decisions that ARE this component
Get these wrong and you have a wobbly stripe; get them right and it reads as art direction.
1. Register the custom property with @property. This is not optional.
@property --shape {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
An unregistered custom property is an opaque string to the engine. clip-path: polygon(… calc(60% - var(--shape) * 30%) …) will still compute, but the browser cannot interpolate --shape, so any CSS transition on it jumps from 0 to 1 instead of easing. Registered as <number> it becomes a genuinely animatable value — and initial-value: 1 guarantees the shapes are fully open if the JS never runs (bundler failure, JS disabled, error earlier on the page). Fail open, not flat.
2. The boundary belongs to the INCOMING section, and uses background: inherit.
Each section that wants a shaped top edge contains, as its first child, one empty element:
<section class="panel ink">
<div class="edge" data-shape="chevron"></div>
…
</section>
.edge {
position: absolute;
top: 0; left: 0; right: 0;
height: clamp(48px, 8vw, 128px);
background: inherit; /* ← the whole trick */
transform: translateY(calc(-100% + 1px)); /* lift it out, over the previous section */
pointer-events: none;
z-index: 2;
}
That + 1px is an overlap, and its sign is load-bearing. With a plain translateY(-100%) the boundary's bottom edge lands exactly on the section's top edge, and sub-pixel rounding lets a 1px line of the previous section's colour through — visible as a hairline stripe running the full width, right across your new section. Nudging it 1px *down* buries the join inside the panel. (Writing inset: -1px 0 auto 0 instead pushes it the wrong way and *causes* the stripe. This was a real bug in the first build of this component; it is invisible in code review and obvious in a screenshot.)
background: inherit means the edge colour and the section colour are literally the same value. They cannot drift apart when someone re-dresses the palette — which is exactly the bug you get if the edge is owned by the *outgoing* section and its colour is hard-coded to match the next one. Assign the shape by attribute (data-shape), never by section colour.
3. overflow-x: clip on every section.
.panel { position: relative; overflow-x: clip; }
A boundary drawn wider than the viewport — or any mechanic that pushes elements out of frame — lengthens the document and produces horizontal scroll. Use clip, not hidden: clip does not create a scroll container, so unlike hidden it will not break position: sticky on anything nested inside.
The shapes
Every polygon's bottom edge is always 0% 100% → 100% 100%. That is the line welded to the section; it never moves. Only the top vertices depend on --shape, from 0 (flat) to 1 (full).
.edge[data-shape="wave"] { /* wave — three crests, the softest */
clip-path: polygon(0% 100%,
0% calc(62% - var(--shape) * 26%),
25% calc(24% + var(--shape) * 40%),
50% calc(70% - var(--shape) * 34%),
75% calc(24% + var(--shape) * 34%),
100% calc(58% - var(--shape) * 24%),
100% 100%);
}
.edge[data-shape="chevron"] { /* one central vertex, points down */
clip-path: polygon(0% 100%,
0% calc(100% - var(--shape) * 42%),
50% calc(100% - var(--shape) * 100%),
100% calc(100% - var(--shape) * 42%),
100% 100%);
}
.edge[data-shape="oblique"] { /* corner to corner, the most aggressive */
clip-path: polygon(0% 100%,
0% calc(100% - var(--shape) * 96%),
100% calc(100% - var(--shape) * 8%),
100% 100%);
}
Torn edge — twelve points at *fixed* heights. Fixed is the decision: a torn edge that re-randomises on every load does not read as torn paper, it reads as a rendering bug.
The x-positions are 9% apart except the last one, which jumps from 90% to 100% so the polygon closes on the right edge instead of leaving a 1% straight sliver:
.edge[data-shape="torn"] {
clip-path: polygon(
0% 100%,
0% calc(100% - var(--shape) * 34%), 9% calc(100% - var(--shape) * 72%),
18% calc(100% - var(--shape) * 41%), 27% calc(100% - var(--shape) * 88%),
36% calc(100% - var(--shape) * 56%), 45% calc(100% - var(--shape) * 95%),
54% calc(100% - var(--shape) * 48%), 63% calc(100% - var(--shape) * 79%),
72% calc(100% - var(--shape) * 37%), 81% calc(100% - var(--shape) * 84%),
90% calc(100% - var(--shape) * 52%), 100% calc(100% - var(--shape) * 68%),
100% 100%);
}
Every y is calc(100% - var(--shape) * H%), so --shape: 0 flattens all twelve to the baseline and the edge disappears — which is what makes the whole set animatable from one custom property.
Arch — do this one with border-radius, not a polygon:
.edge[data-shape="arch"] {
clip-path: none;
border-radius: calc(var(--shape) * 50%) calc(var(--shape) * 50%) 0 0 /
calc(var(--shape) * 100%) calc(var(--shape) * 100%) 0 0;
}
A true curve approximated with polygon vertices shows visible facets at large boundary heights.
Motion
One ScrollTrigger per boundary. Nothing else.
gsap.fromTo(edge, { "--shape": 0 }, {
"--shape": 1,
ease: "none",
scrollTrigger: {
trigger: edge.closest(".panel"),
start: "top bottom",
end: "top 55%",
scrub: 0.6,
invalidateOnRefresh: true,
},
});
Why this window and not another. The shape opens as the edge appears from the bottom and is finished by the time the section has risen past mid-screen. If you let it run to top top, the edge is still moving while the reader is already on the headline, and the boundary competes with the text — that is the mistake that makes this effect look cheap. The movement must be over before the reading starts.
scrub: 0.6, not scrub: true. The lag makes the edge *chase* the scroll rather than being welded to it. With a hard scrub, a mouse wheel with coarse steps makes the shape jump between states and the polygon visibly stair-steps.
Refresh after fonts load. Web fonts change headline heights and therefore every section's position. Without this the triggers stay computed against the fallback-font layout and the edges open out of sync with their sections:
if (document.fonts?.ready) document.fonts.ready.then(() => ScrollTrigger.refresh());
Responsive and reduced motion
Only the oblique form needs a width correction — at 390px a full-slope diagonal eats half the screen. Cut its travel to ~62% below 640px.
For prefers-reduced-motion: reduce, lock the shape open and create no triggers at all:
@media (prefers-reduced-motion: reduce) { .edge { --shape: 1 !important; } }
The art direction survives; only the animation goes. That is what the setting asks for.
Choosing a form (this is the part that is taste, not code)
- Wave — softest, points at nothing. Best above long text blocks.
- Chevron — points down, so it pushes the reader onward. Good before a section that continues
an argument; bad before a grid, where it fights the grid's own alignment.
- Oblique — most aggressive, strongly directional. One per page, maximum.
- Torn — carries a material connotation (paper, packaging, print). Only use it if the brand
can carry that; on a fintech page it reads as a mistake.
- Arch — the quietest. The page breathes and nobody can say why.
Do not use a different form for every boundary on a real page. The demo shows five so you can compare them. A finished page picks one, at most two, and repeats. Five different edges is a sampler, not art direction.
Adapting
Everything except the three decisions above is dressing. Change the palette, the section content, the boundary height (--alto-edge), the number of sections, the type. Add new forms by writing another polygon() whose top vertices multiply by var(--shape) and whose bottom edge stays at 100%. Keep: the @property registration, background: inherit, overflow-x: clip, the window that closes at top 55%, and the soft scrub.
Adapting this to React
Everything above describes a standalone document: a script that waits for DOMContentLoaded, bails out under prefers-reduced-motion before creating anything, otherwise wires one document-level Lenis instance into GSAP's ticker, walks every .edge on the page with gsap.utils.toArray(".edge").forEach(...) to give each boundary its own scrubbed tween-and-trigger pair, and finally waits on document.fonts.ready to refresh those triggers once headline metrics settle. React withdraws the free run of the page, the one-shot lifetime, and the licence to never tear anything down — quietly: the boundaries open on schedule the first time, and the damage only shows up on a second mount.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This setup is short but leaves four live things behind: a Lenis instance, the gsap.ticker callback pumping it, one tween-and-ScrollTrigger pair per .edge (five in the demo — wave, chevron, oblique, torn, arch), and a pending document.fonts.ready continuation. A double mount that doesn't undo all four leaves two Lenis instances fighting over the same wheel event, twice the triggers scrubbing the same --shape custom property on the same nodes, and a second ScrollTrigger.refresh() queued behind the first once fonts settle.
*(1) The entry point* — the whole body sits inside document.addEventListener("DOMContentLoaded", ...). A React component mounts after that event has already fired, so the listener registers and is never called: no Lenis, no triggers, every edge stays flat forever with nothing in the console to point at. Delete the listener and move its body — the reduced-motion check and its early return, the Lenis construction and ticker wiring, the gsap.utils.toArray(".edge").forEach(...) loop, and the document.fonts.ready call — into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) can move to module scope; re-registering on every mount is harmless but pointless. The reduced-motion branch stays trivial either way: it returns before creating anything, so on that path the effect has nothing to clean up.
*(2) Element lookups* — gsap.utils.toArray(".edge") is the lookup that needs scoping: called synchronously inside a gsap.context factory, GSAP resolves any selector string against that context's root instead of the whole document, so wrapping the loop in a context scoped to this component's root ref is enough — no manual root.querySelectorAll rewrite needed. The very next line, edge.closest(".panel"), needs no change at all: it climbs the ancestor chain from an edge node you already hold, not from a fresh document-wide selector, so once edge itself resolves inside this component's subtree, the panel .closest finds is already the right one.
*(3) Cleanup* — wrap the reduced-motion check and the .forEach loop in a gsap.context scoped to the root ref:
useEffect(() => {
const ctx = gsap.context(() => {
/* the reduced-motion check, then the toArray(".edge").forEach loop */
}, rootRef);
return () => ctx.revert();
}, []);
ctx.revert() covers exactly the tween-and-ScrollTrigger pair gsap.fromTo creates for each .edge — as many pairs as the page renders, all created synchronously inside the loop — and nothing outside that loop. It does not cover gsap.ticker.add((t) => lenis.raf(t * 1000)): a ticker subscription is neither a tween nor a trigger, so the context never records it, and this is exactly the case where that gap matters most — that callback is what keeps Lenis's own frame loop alive. Keep the reference and remove it before destroying Lenis:
const onTick = (t) => lenis.raf(t * 1000);
gsap.ticker.add(onTick);
// cleanup, in this order:
gsap.ticker.remove(onTick);
lenis.destroy();
ctx.revert();
Reverse the first two calls and a ticker frame landing between them calls .raf() on a Lenis instance you have already destroyed.
Lenis here is this component's own document-level resource — it drives every boundary's scrub, not just one. If the shaped sections are one route or one section of a larger app, lift the new Lenis() call to the app shell and have this effect read the shared instance instead of constructing a second one; if it genuinely owns the whole page's scroll, construct it in the effect as shown. lenis.on("scroll", ScrollTrigger.update) needs no separate teardown of its own — in this Lenis version destroy() clears the instance's internal emitter along with it — which is exactly what makes the ticker callback the one piece that does need explicit removal: it lives on GSAP's ticker, a resource Lenis does not own and destroy() cannot reach.
The remaining loose end is document.fonts.ready.then(() => ScrollTrigger.refresh()). That promise can resolve after a StrictMode unmount, or after a real navigation away, and its callback would then call a global refresh on behalf of a component whose triggers ctx.revert() has already removed. Guard it with the same cancellation flag the cleanup sets:
let cancelled = false;
document.fonts.ready.then(() => {
if (cancelled) return;
ScrollTrigger.refresh();
});
return () => {
cancelled = true;
gsap.ticker.remove(onTick);
lenis.destroy();
ctx.revert();
};
Leave the refresh call itself un-scoped and fired once, exactly as above — it exists because a web-font swap changes headline heights and therefore every panel's position, and limiting it to this component's own triggers would leave every other panel on the page still measured against the fallback-font layout.