Arch Portal Reveal — a doorway that grows until it is the whole frame
Goal
Build a pinned stage where an arch-shaped opening grows from a doorway to the full viewport as you scroll, revealing the scene behind it. The scene does not move, does not scale, does not reframe: only the opening changes size.
Use it as a transition into a place — an interior, an exhibition, a chapter that is somewhere *else*. The mechanic is literally walking through a door, so it pays for its height only when the thing on the other side is worth arriving at.
Tech
Vanilla HTML/CSS/JS with ES modules: gsap + ScrollTrigger, and lenis. No plugins beyond ScrollTrigger.
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.
Structure
<section class="gate">
<div class="track"> <!-- tall: 260svh -->
<div class="stage"> <!-- sticky, 100svh, overflow hidden -->
<div class="reveal"> <!-- THE OPENING — this is what grows -->
<div class="scene"> <!-- 100vw × 100svh, never changes size -->
<img src="…" alt="" width="900" height="1200">
<div class="scene-copy">…</div>
</div>
</div>
</div>
</div>
</section>
Two nested boxes and nothing else. The whole component is the relationship between them.
The four decisions that ARE this component
1. The opening grows. The scene never does.
This is the entire trick, and it is the one thing that separates this from a zoom.
.reveal { position: absolute; left: 50%; bottom: 0; transform: translateX(-50%);
width: var(--w); height: var(--h); overflow: hidden; }
.scene { position: absolute; left: 50%; bottom: 0; transform: translateX(-50%);
width: 100vw; height: 100svh; }
The obvious implementation animates scale on a container. Everything inside scales with it: the headline balloons, the photograph loses its crop, and the result reads as pushing in with a camera — not as an opening widening in front of you.
Growing --w / --h on a box with overflow: hidden, while the content keeps its own viewport-sized box, is what makes it read as a door. You are revealing more of a fixed thing, not enlarging a small thing.
2. Both boxes anchor to the SAME point: bottom centre.
left: 50%; bottom: 0; transform: translateX(-50%) on both. If the scene is centred in its parent instead, it drifts as the parent grows — a slow slide that nobody can name but everybody notices, and it destroys the illusion that you are looking through a stationary opening. Sharing the anchor means the scene sits still in viewport coordinates for the whole travel.
3. The corner radius falls FASTER than the box grows.
const narrow = () => innerWidth < 760;
const open = (p) => {
const w0 = narrow() ? 58 : 26; // starting width in vw
const h0 = narrow() ? 28 : 34; // starting height in svh
reveal.style.setProperty("--w", `${w0 + (100 - w0) * p}vw`);
reveal.style.setProperty("--h", `${h0 + (100 - h0) * p}svh`);
reveal.style.setProperty("--r", `${50 * Math.pow(1 - p, 1.9)}%`); // <- its own curve
};
The door starts at 26vw × 34svh on desktop and 58vw × 28svh below 760px. The CSS declares the same pair as the initial --w / --h so the first paint is correct before any script runs; the JS recomputes w0/h0 on every call, which is why a resize across the breakpoint is picked up (the trigger's onRefresh fires open() again). Those two numbers living in two files is the one piece of duplication here — if you change one, change the other.
Width and height are linear; the radius is not. Animate the radius linearly and the shape spends most of the scroll as a lozenge and is only recognisably an arch in the first few percent — which is the part nobody is looking at yet. The 1.9 exponent pulls the radius down ahead of the box, so the silhouette is unmistakably an arch through the first third and has squared off by the time it fills the frame.
The arch is border-radius, not clip-path: a true curve with no facets, and two numbers to animate instead of a polygon whose vertex count has to stay constant across states.
border-radius: var(--r) var(--r) 0 0 / calc(var(--r) * 0.9) calc(var(--r) * 0.9) 0 0;
The two-axis form (/) is what makes it a doorway rather than a semicircle — the vertical radius is slightly shorter than the horizontal one, which is the proportion of an actual arched opening.
4. No scrub. Write on every update.
ScrollTrigger.create({
trigger: ".track", start: "top top", end: "bottom bottom",
onUpdate: (s) => open(s.progress),
onRefresh: (s) => open(s.progress),
invalidateOnRefresh: true,
});
Lenis already smooths the scroll. A scrub on top of it inserts a second lag, and the door visibly trails the wheel — on a mechanic this literal, that lag reads as the page being slow rather than as easing. onRefresh matters as much as onUpdate: without it a resize or a late font leaves the door at whatever size it happened to have.
Pacing
min-height: 260svh on the track — 160svh of actual travel with the stage pinned. That is the only number that controls the pace, so put it somewhere obvious. Below ~200svh the door snaps open before the reader has registered there is a door.
Responsive
The starting size has to change, not just shrink with the units:
@media (max-width: 760px) { .reveal { --w: 58vw; --h: 28svh; } }
26vw on a phone is a slot, not a doorway — too narrow to show that there is a scene behind it. And the scene collapses to one column with a shorter image, because it must be readable at every width including the very first frame, when only a sliver of it is visible.
Reduced motion
Open the door and leave it open:
@media (prefers-reduced-motion: reduce) {
.track { min-height: 0; }
.stage { position: static; height: auto; }
.reveal { --w: 100vw !important; --h: 100svh !important; --r: 0% !important; }
}
Bail out of the JS before creating any trigger. Collapsing the track matters — otherwise there are 260svh of empty scrolling after a scene that is already fully visible.
Adapting
Change the palette, the type, the subject, the arch proportion (the 0.9 vertical ratio), the radius exponent within roughly 1.5–2.5, the travel height. The mechanic is *an opening that grows around a stationary scene* and it works for any arrival: a building interior, a garden, a storefront, a chapter break.
Keep: the two boxes sharing a bottom-centre anchor, the scene at fixed viewport size, the radius on its own faster curve, border-radius rather than clip-path, and no scrub.