A real estate landing page built from the City Arcade reference design: oversized wide-grotesque type, stacked off-white panels, and a floating glass structure as the hero artwork.
npm install
npm run dev # http://localhost:5173
npm run build # type-check + production bundle into dist/
npm run preview # serve the built bundleRequires Node 20.19+ or 22+.
- React 19 + TypeScript on Vite 6
- Tailwind CSS v4 — design tokens live in the
@themeblock at the top ofsrc/index.css - Framer Motion for all animation
- Lenis for smooth scrolling
No image assets. Every visual — the hero structure, the per-listing artwork, the palms — is generated from CSS or SVG, so the page has nothing to download and everything can be animated.
src/components/GlassStructure.tsx builds the hero building as real CSS 3D boxes
(transform-style: preserve-3d, six faces each) rather than an image. That is what lets it tilt
toward the pointer, drift on scroll, and re-light itself with a travelling specular sweep. It is
laid out against a fixed 980×560 design frame and scaled to fit whatever panel it is dropped into,
with zoom / offsetX / offsetY to re-compose it per section.
src/components/PropertyVisual.tsx draws each listing's elevation procedurally from a hash of its
id (seeded() in src/lib/utils.ts), so a given property always renders the same building.
| Feature | Where |
|---|---|
| Preloader with counter and rolling city names | Preloader.tsx |
Custom cursor — magnetic, grows on links, labels via data-cursor |
Cursor.tsx |
| Smooth scroll, scroll-progress bar, grain overlay | lib/hooks.ts, Chrome.tsx |
| Scroll-aware nav, full-screen mobile menu, ⌘K / Ctrl+K search | Nav.tsx |
| Pointer-tracked 3D hero, split-text reveals, counting stats | Hero.tsx |
| Per-word colour fill driven by scroll position | Statement.tsx |
| Feature cards that swap the panel artwork and metric | Features.tsx |
| Velocity-reactive ticker (speeds up and reverses with the scroll) | Marquee.tsx |
| Listing browser: search, type/status filters, sort, shortlist | Properties.tsx |
| Listing detail sheet — ← / → to move through listings, Esc to close | PropertyModal.tsx |
| Mortgage calculator with live repayment maths and balance curve | Calculator.tsx |
| Drag-and-snap testimonial carousel | Voices.tsx |
| FAQ accordion, validated enquiry form, footer clock | Faq.tsx, Contact.tsx, Footer.tsx |
The shortlist persists to localStorage under city-arcade:shortlist.
The artwork is cheap to look at and expensive to animate, so a few rules are
load-bearing. All of these were measured, not guessed — ablating each effect and
sampling requestAnimationFrame deltas. Idle went from 1.5fps to ~55fps and
scrolling improved roughly tenfold.
- Only the on-screen scene is mounted.
GlassStructurekeeps its flat backdrop but unmounts thepreserve-3dsubtree when off screen. Four live 3D scenes is what the page cannot afford; one is fine. - Nothing animates
scaleXorlefton a gradient-filled element. Both force a fresh raster every frame. The old motion-blur streaks did exactly this and cost the entire idle frame budget for a barely visible detail, so they were removed. The specular sweep animatestransform/opacityonly, goesvisibility: hiddenwhile idle, and runs on the hero alone. - Scroll parallax never moves the 3D stack. Moving an ancestor of a
preserve-3dsubtree re-renders every face each frame. Only.gsky— a promoted, flat layer — parallaxes. - The grain is a baked bitmap with no blend mode. An inline SVG
feTurbulencebackground re-runs the filter whenever its layer is invalidated, andmix-blend-multiplyon a fixed overlay forces a full-viewport re-blend on every scrolled frame. - Pointer handlers never read layout.
GlassStructurecaches its page-relative box and refreshes it on resize, not on scroll or pointermove.
src/lib/perf.tsx samples frame pacing for two seconds after load. If the
device is visibly struggling it drops the grain, the sweep and the pointer
tilt — the layout and the meaningful motion are unaffected.
Split-text reveals are triggered from their wrapper, not per character. Each character starts
translated a full line below an overflow: hidden parent, so its clipped rect is empty and
whileInView on the character itself would never fire. SplitChars / SplitWords put one
IntersectionObserver on the wrapper and drive the characters from it. The same applies to the
<g> groups in PropertyVisual — the stagger is declared on the <svg>.
.clip-line only sets overflow: hidden. It deliberately does not set display, so the
element's own utility class decides. Forcing display: block there stacks a headline one letter
per line.
prefers-reduced-motion is honoured: the preloader is skipped, Lenis never mounts, and CSS
animations are suppressed. The custom cursor is only bound on fine pointers, headings keep their
text accessible through aria-label, and every icon-only control is labelled.