Overview
A single luxury residential development, one page, and scroll doing all the work. A plum curtain opens through an arch-shaped hole that widens and flies off the top of the screen. The hero holds still while the camera dives into it. A four-word sentence pulls itself apart. The location section turns sideways. The amenities grid scales up until the next section is revealed through it. Two clip-path curtains open like an aperture. Then the whole page clips inward into a card and the footer rises up behind it.
No two sections animate the same way, and every entrance has a matching exit — scroll back up and it rewinds rather than re-triggering. A small mark in the top-left corner never stops spinning, and it spins faster when you flick and reverses when you scroll up.
I wrote this after taking apart era-residence.com section by section. That post is the teardown — what the site does and why it works. This one is the build: the whole thing turned into a single brief for Replit Agent, with the reference site's own bugs fixed on the way in.
What it produces: a masked arch preloader built from four composited CSS mask layers, a sticky-scroll hero dive, a word-spacing spread, a horizontal location track whose length is derived from its own content, a zoom-through hand-off, clip-path architecture curtains, a page that clips itself into a card at the footer, six reveal primitives that each have a mirrored exit, five parallax variants, a velocity-tracking corner mark, and a draggable progress rail with a two-digit counter.
Stack: vanilla HTML/CSS/JS modules on Vite, GSAP (ScrollTrigger, SplitText, CustomEase) and Lenis. No framework, no WebGL, no canvas — every effect is a transform, an opacity, a clip-path or a mask.
Supply the imagery first
This is a site where the interface is deliberately three flat colours and all the richness comes from photography. That means the assets are not decoration you add at the end — they are the thing the choreography is choreographing. Get them in place before the agent wires anything.
- One hero image at 4K, shot with room to fly into. The hero dive scales it to 2× from a transform origin at 75% height. If the subject already fills the frame, the end of the dive is a crop of nothing. Shoot or source wide.
- An establishing aerial for the full-bleed location frame. It carries that section on its own — no scrim, no gradient, no overlay, one letterspaced caption in a corner. A mediocre aerial has nothing to hide behind here.
- Three to five decorative video loops, 3–6 seconds, silent. Movement in a place: foliage in wind, water, light moving across a wall. No people, no story. Encode both MP4/H.264 and WebM/VP9 — the reference site is WebM-only and strips WebM for Safari, so all eight of its loops simply do not exist there.
- A radially symmetric corner mark. It rotates forever. A horizontal wordmark rotating reads as a logo that has fallen over; a circular ring around a small glyph reads as motion. Design it for the animation, not the other way round.
The prompt
Paste the whole thing at once.
Why the prompt is written this way
The first version of this brief was three sentences: a luxury property
site, smooth scroll, lots of scroll animation. What came back was
competent and completely inert — a stack of sections that each faded up
from opacity: 0 with a 40px rise, the same reveal every
time, smooth scroll bolted on top. Nothing was broken. It just had no
choreography, because "lots of scroll animation" is not a spec, it is an
adjective.
Every clause below is in the prompt because its absence produced a specific, repeatable failure of that kind.
Ban the pin
Do not use ScrollTrigger's pin: true.
This is the clause I would keep if I could keep only one. Ask any agent
for a section that holds still while its contents animate and you get
pin: true, because that is what every tutorial and every
answer on the internet says. It works, in isolation. Combined with
Lenis it produces about a pixel of jitter at each pin boundary, and it
does it on a real machine at 120Hz in a way you will spend an afternoon
failing to reproduce in a screenshot.
The reason is mechanical: pinning injects a pin-spacer element and
toggles the pinned element between position: fixed and
static, so both boundaries force layout while a smooth-scroll engine is
interpolating scroll position on the same frames. A tall wrapper with a
position: sticky child never touches document flow — the
browser handles it on the compositor, and there is no boundary to cross.
So the prompt does not describe the effect and hope. It names the
structure — wrapper at 2–4 viewport heights, sticky child at
top: 0; height: 100vh, one scrubbed ScrollTrigger from
top top to bottom bottom — and then bans the
alternative by name. Banning by name matters. "Prefer CSS sticky" gets
you sticky in the section that was described first and
pin: true in the four the agent wrote afterwards.
Give the sections different behaviours, explicitly, one by one
Do not build one reveal component and reuse it everywhere. That is exactly what makes a site feel templated.
Agents optimise for consistency, and consistency is the enemy here. Left
alone, it will factor out a single revealSection() and call
it six times, which is genuinely better code and a worse site. What
makes this kind of page feel authored is that you cannot predict what
the next section will do.
The fix is to spend the length. Each section gets its own numbered step, its own mechanic, its own wrapper height, and its own numbers — 340vh for the dive, 285vh for the zoom-through, 190vh for the curtains. A table of "section → behaviour" alone does not survive; the agent reads it as a summary of something it can implement generically. Six separate specs cannot be collapsed.
Derive the horizontal length instead of picking one
Set the wrapper's height from the track's width so vertical and horizontal distance are 1:1 by construction.
Horizontal scroll sections are where hand-tuned magic numbers go to die.
The usual implementation gives the wrapper something like
300vh and translates the track by its overflow width, which
means the mapping between the two depends on the viewport, the font
rendering, and how many cards the content happens to have today. It
feels right on the machine it was tuned on and drags or races
everywhere else.
Setting area.style.height = track.scrollWidth + "px" makes
one pixel of vertical scroll equal one pixel of horizontal travel,
permanently, with no constant to tune. The
ScrollTrigger.refresh() immediately after is not optional —
you have just changed the height of the document from JavaScript, and
every trigger below it is now measured against a stale layout.
The containerAnimation clause is in the prompt because its
absence is a silent failure. Reveals inside a horizontally translated
track never fire: the elements never enter the viewport vertically, so
their triggers never activate, and the section looks like it lost half
its content. It also needs the axis words swapped —
start: "left bottom", not "top bottom" — which
is the part that gets missed even when containerAnimation
is remembered.
Scrub geometry, fire type
Backgrounds, scale and clip scrub; type never does.
Scrub is intoxicating and it is wrong for text. Words tied to scroll position move as fast as the wheel does, stop when you stop, and reverse when you scroll back — which means they are never held still long enough to be read, and the reader feels responsible for a job the page should be doing. Every "premium" site that is exhausting to actually read is exhausting for this reason.
The footer in this prompt is the demonstration: the page clip scrubs
with scroll, while the footer text fires discretely from
onEnter and hides on onLeaveBack, in the same
trigger. That hybrid is what a hand-built site does, and it is not what
you get by default — asked for "a scroll-driven footer", an agent
scrubs everything in it, text included.
Every entrance needs its exit named as the opposite
Every primitive needs a mirrored exit in the opposite direction.
This is the difference between a site that rewinds and a site that re-triggers, and it is almost never implemented, because it costs twice the work for something only visible on the way back up. Ask for reveals and you get reveals: elements animate in, and on the return trip they either sit there or play the same entrance again, which reads as a glitch — you scrolled backwards and something moved forwards.
Writing the primitives as a three-state function
("reveal" | "hide" | "initial") with a table whose "hide"
column is literally the sign-flipped "reveal" column is what makes this
cheap to specify and hard to get wrong. The asymmetry in timing is
deliberate too: durL with an out-ease to enter,
durS with an in-ease to leave. Exits should be faster than
entrances, because an exit is a thing getting out of the way.
Name the two list rules, or the grid staggers forever
Group by wrapper, and strip data-reveal from every
sibling after the first.
Both of these are invisible in a spec and obvious in a build. Without wrapper grouping, a heading and its paragraph get separate triggers, so they fire a few pixels apart and the pairing that makes a stagger feel composed is gone. Without the first-card rule, a grid of thirty apartment cards staggers thirty times at 0.1s each and the last card arrives three seconds after you got there.
once: true on every reveal trigger is in the same family.
On the reference site it takes the live trigger count from about 156 to
about 82 as you read down the page — half the scroll handlers delete
themselves once they have done their one job.
Give the ambient motion its physics in numbers
Idle at 30 deg/sec, 30 + 10 * Math.abs(velocity), back
to idle 100ms after you stop.
A logo that spins in response to scroll is a two-line idea and a surprisingly deep spec. Asked for it plainly, you get rotation mapped directly to scroll position — which means it stops dead the instant you stop, and a thing that stops dead reads as broken rather than as responsive.
The behaviour that works is a constant idle spin with velocity as a
modifier, tweened rather than assigned, and eased back to idle after a
short delay. Three details in the prompt exist purely because their
absence is a visible bug: the Math.min(delta, 100) clamp,
or the mark makes a full jerking revolution the moment you return to a
backgrounded tab; the userScrolled gate, or the loader's own
programmatic scrollToTop spins it before anyone has
touched anything; and overwrite: true on the speed tween,
or a hundred queued tweens fight over one property.
Overlap the preloader hand-off, and say so
Animate the hero from 0.75 to 1 on the same frames as the arch dive. Do not sequence them.
The arch opening and the hero settling are two animations that have to
be perceived as one camera move. Sequenced — arch leaves, then hero
scales — you get a loader followed by an effect, and the seam is
obvious. Overlapped at "<", the arch flies past a hero
that is still growing, and the eye reads a single push through a
doorway.
Agents sequence by default, because a timeline is a list and a list is
ordered. "Do not sequence them" has to be said out loud, as does the
"<90%" position on the dive — starting the dive at 90% of
the previous tween rather than after it is what removes the pause where
the arch sits open doing nothing.
Fix the reference site's bugs in the brief
Building from a reference means inheriting its decisions, including the bad ones. Four of these are called out in the prompt as corrections rather than as features, because otherwise a faithful implementation reproduces them:
-
No
prefers-reduced-motionbranch. The reference site has none — the smooth scroll, the scrubs and the snapping all run regardless. This is the one non-negotiable that is purely a fix rather than a match. -
Breakpoint read once at build time. Parallax
initialised outside
gsap.matchMedia()is correct until someone resizes across 992px, after which desktop-only tweens are running on a phone-width layout. Wrapping every variant inmatchMediamakes it rebuild instead. - WebM-only video with Safari stripped. Eight decorative loops that exist for most visitors and silently do not for the rest. Ship both codecs.
- Section snapping. Snapping to whichever section covers half the viewport 40ms after you stop scrolling actively fights a reader who stopped on purpose. The prompt drops it, and says why — otherwise it comes back as a feature.
End with checks the agent can actually fail
The acceptance list is written as observable behaviour, not as
intentions. "Corner mark reverses when you scroll up" either happens or
it does not. "Second visit in the same session plays the short loader"
is one reload away from a verdict. "Everything exits in the opposite
direction it entered" is the only way to catch a missing
"hide" state, because nothing about the page looks wrong
until you scroll back up through it.
Each line in that list is a bug I have watched this prompt produce without it.
What goes wrong, and what it means
-
A pixel of jitter at the top or bottom of a sticky
section.
pin: truesurvived somewhere. Search the build for it — it is usually in a section that was added after the first pass. -
The hero dive is offset, or overshoots by a screen.
The timeline was built before the hero image decoded, so
bg.offsetHeightwas measured on a zero-height box. Gate the build onimg.completeor theloadevent. -
Nothing inside the horizontal track ever appears.
Missing
containerAnimation. If it is present and reveals still do not fire, the trigger is still usingstart: "top bottom"instead of"left bottom". -
The horizontal section snaps at the very end. The
track had not finished parking before the section left the viewport.
That is what the
2.5% / 97.5%buffer is for. -
Everything below the location section triggers at the wrong
place. The wrapper height was set from JavaScript without a
following
ScrollTrigger.refresh(). -
The word-spacing section drops frames badly. Either
the line is too long, or something else is animating in that section.
word-spacingtriggers layout on every scrub tick — it can have the section to itself. -
Images look soft or shimmer while scaling in Safari.
Missing
translateZ: 10on the scaled layer, so it is being resampled at every step instead of composited. -
Paragraphs break mid-line after a resize. SplitText
without
autoSplit, or with it but noonSplitto re-apply the hidden state, so the re-split lines land visible. - The corner mark makes one violent revolution when you come back to the tab. Unclamped ticker delta.
-
The mark is already spinning during the loader. The
userScrolledgate is missing and the loader's ownscrollToTopregistered as scroll input. - The loading bar moves at a constant rate. A standard ease instead of the irregular hand-drawn one. A loader that moves linearly looks fake, because real loading does not.
-
The arch opens, pauses, then dives. The dive was
sequenced after the open instead of starting at
"<90%". - The page jumps sideways when the loader engages. Scroll lock did not measure the scrollbar width into a custom property before hiding overflow.
-
Scroll feels laggy rather than weighty. Wrong Lenis
easing — a linear or
power2curve instead of the exponential one. ChecklagSmoothing(0)too. -
Scrubs desync from the wheel after a stutter.
gsap.ticker.lagSmoothing(0)is missing, so GSAP fast-forwards through the frames it dropped. -
Every card in a grid animates. The
data-reveal-firstrule was not applied. -
Scrolling back up feels like the page glitching.
Reveal primitives were built without their
"hide"states. -
The corner mark disappears over one particular
section. That section is missing its
data-bgtag, so the theme swap never fires for it. -
Videos never play, or play everywhere at once.
autoplaywas left on instead of the section being gated by a ScrollTrigger — or the trigger exists but has noonEnterBack, so nothing resumes on the way up.
Recap
-
Ban the wrong technique by name. "Prefer sticky" is advice;
"
pin: trueis forbidden" is a constraint, and only one of them survives into the fifth section the agent writes. - Specify every section separately, with its own numbers. Anything you express as a general rule will come back as one reusable component, which is better code and a worse site.
- Derive dimensions from content wherever you can. A horizontal track whose wrapper height equals its own scroll width has no magic number to tune and cannot drift.
- Scrub geometry, fire type. Text tied to scroll position is never held still long enough to read.
- Name the exit for every entrance. It is half the work of the reveal system and the entire difference between rewinding and re-triggering.
- Ambient motion needs literal physics — an idle rate, a velocity coefficient, a settle delay. "Responds to scroll" gets you rotation mapped to position, which stops dead and reads as broken.
- Say "do not sequence these" where two animations must be perceived as one. Timelines are lists, and an agent will order anything orderable.
- Define the timing tokens once, up front, and require every tween to use them. Six eases and three durations are what make a site feel authored rather than assembled.
- Correct the reference's mistakes in the brief, marked as corrections. A faithful build reproduces the missing reduced-motion branch as diligently as it reproduces the good parts.
-
Put the invisible structural rules in writing — wrapper grouping,
first-card-only staggers,
once: true,ScrollTrigger.refresh()after a height change. Nothing about the page looks wrong when they are missing until it does. - End with checks that can fail on observation, each one a bug you already know the prompt will otherwise produce.
The teardown this came from is here. The companion brief — the same idea taken into WebGL, with scroll-scrubbed WebP frame sequences instead of transforms — is the pear.no-style cinematic site prompt. If you want more prompts in this shape, there is a floor lamp hero with a brass switch that lights the whole page, an interactive light gallery with pull-chain physics, and a kinetic wind wall in a vertex shader.