Skip to content

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

City Arcade

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.

Running it

npm install
npm run dev      # http://localhost:5173
npm run build    # type-check + production bundle into dist/
npm run preview  # serve the built bundle

Requires Node 20.19+ or 22+.

Stack

  • React 19 + TypeScript on Vite 6
  • Tailwind CSS v4 — design tokens live in the @theme block at the top of src/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.

The artwork

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.

What is interactive

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.

Performance

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. GlassStructure keeps its flat backdrop but unmounts the preserve-3d subtree when off screen. Four live 3D scenes is what the page cannot afford; one is fine.
  • Nothing animates scaleX or left on 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 animates transform/opacity only, goes visibility: hidden while idle, and runs on the hero alone.
  • Scroll parallax never moves the 3D stack. Moving an ancestor of a preserve-3d subtree 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 feTurbulence background re-runs the filter whenever its layer is invalidated, and mix-blend-multiply on a fixed overlay forces a full-viewport re-blend on every scrolled frame.
  • Pointer handlers never read layout. GlassStructure caches 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.

Two things worth knowing before you edit

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.

Accessibility

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.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages