# 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.

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
```

Wire Lenis to ScrollTrigger — this is not optional:

```js
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

```html
<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.

```css
.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.

```js
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.

```css
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.

```js
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:

```css
@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:

```css
@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`.
