Skip to content

01Luxury Real EstateConcept · Live build

Casa Noir

A home should be experienced.

Visit live site (opens in a new tab)
Type
Concept build, fictional brand
Year
2026
Stack
Next.js 16 · React 19 · TypeScript · Tailwind v4 · GSAP · Lenis · Three.js
Scale
20 sections · ~5,800 lines
01 / Problem

Listings flatten architecture.

Property sites sell square footage. A residence like this is bought on how it feels to arrive, move through it and live in it.

“The entire website should feel like a digital architectural film.”
Project brief, requirement.md

The brief asked for the opposite of a real-estate portal: a site that sells the feeling of owning one specific house, told in the order you would experience it — the approach, the door, the rooms, the day turning to night.

It also set the constraints that shaped every decision: restraint over spectacle, a 2.5D experience over slow 3D, and one system that a developer could reuse for the next property.

Feel
A film, not a portal
Reuse
One template, many properties
Frame-rate goal
60 fps on modern desktops
A target from the brief, not a measurement
02 / Concept

The camera is the scroll.

Scroll position drives a camera through the house. Each chapter of the page is one continuous shot rather than a set of animated blocks.

The opening: approach, then the camera moves in.Three photographs crossfade and scale inside one pinned, scrubbed ScrollTrigger timeline.

Six signature moments

  1. 01Arrival

    Gate, path, water, door — one continuous shot.

  2. 02Terrace

    The house through a day: scroll is the clock.

  3. 03Floor plan → room

    Select a room on the plan and its photograph opens from that shape.

  4. 04Materials

    A horizontal pass over seven macro textures.

  5. 05Studio

    A 3D model of the house, loaded only on request.

  6. 06Location

    A pull-back from the house to the whole country.

Design language

Instrument Serif for display, Inter for interface, an ivory / ink palette with a single bronze accent. Every section declares a light or dark theme and the header recolours itself to match, so the chrome never fights the photography.

03 / System

One template, many properties.

All content lives in typed data files. To rebrand for another property you edit data — the page and animation code don't change.

Layers

  1. 01Content

    Brand, rooms, floor plans, amenities, materials, location — typed TypeScript data. Brand colours flow into CSS variables.

    data/*.ts

  2. 02Scenes

    20 section components, each owning its own GSAP timeline through useGSAP and gsap.matchMedia.

    components/sections/

  3. 03Motion layer

    One place registers ScrollTrigger, sets the default ease and defines the desktop / mobile / reduced-motion conditions every scene uses.

    lib/animation/gsap.ts

  4. 04Scroll

    A single Lenis instance driven by GSAP's ticker, so smooth scroll and ScrollTrigger never drift apart.

    components/providers/SmoothScroll.tsx

  5. 05State

    Local state everywhere, plus one React context to open the booking dialog. No global store.

    components/booking/BookingProvider.tsx

components/providers/SmoothScroll.tsxThe whole scroll system. Under reduced motion Lenis is never created.
const lenis = new Lenis({ duration: 1.25, autoRaf: false });
const raf = (time: number) => lenis.raf(time * 1000);
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);

One source for plan and model

The floor plans are rectangles in a 1000 × 625 coordinate space. The same data draws the interactive SVG plan and generates the 3D model procedurally, so the two can never disagree.

04 / Experience

Moments, not effects.

Each signature moment uses the simplest tool that can do it — a pinned timeline, an SVG viewBox, or plain React state.

Arrival — the gate opens, the path pulls you in, the doors swing.One pinned, scrubbed timeline. The gate is two CSS-gradient panels; the doors are halves of one image rotating ±96° in CSS 3D.
Terrace — 11:00 to 21:00 in one scroll.Four photographs and colour grades crossfade at 25 / 50 / 75 %. The clock is written straight to the DOM from onUpdate, so React never re-renders per frame.
Floor plan — change floors, then open a room from its outline.Walls draw with pathLength + stroke-dashoffset. Selecting a room tweens the SVG viewBox and reveals the photograph through a clipPath shaped like the room.
Smart home — lighting and curtains respond as you drag.Plain React state and CSS transitions. No animation library needed here.

3D, only when asked

Three.js is the heaviest dependency, so it loads through a dynamic import only after “Open the 3D model” is pressed. It renders on demand instead of in a loop, caps the pixel ratio at 2, disposes everything on close, and falls back to text without WebGL2.

05 / Build

What broke, and how it was fixed.

Scroll-driven sites fail in small, specific ways. These fixes are recorded as comments next to the code that needed them.

  1. 01Fonts moved every trigger

    Web fonts change line heights after load. ScrollTrigger refreshes on load, after document.fonts.ready and on the first frame.

    SmoothScroll.tsx

  2. 02Locked scroll mis-measured pins

    Stopping Lenis hides the scrollbar, so pins measured meanwhile were a scrollbar too wide. Unlocking now refreshes ScrollTrigger.

    lib/scroll.ts

  3. 03Resizing replayed the intro

    The intro runs once; the resize path only rebuilds the scroll timeline with explicit start values.

    Hero.tsx

  4. 04Reduced motion never fired

    gsap.matchMedia only calls back when a condition matches, so reduced motion is its own condition, not an absence.

    Terrace.tsx

  5. 05Hydration mismatch from maths

    Server and browser trigonometry can differ in the last digit, so projected map coordinates are rounded before render.

    Location.tsx

  6. 06A GPU layer per word

    Leftover filter and transform after a text reveal kept a layer per word; reveals now clear those properties when done.

    lib/animation/hooks.ts

Same scene, two designs: shorter pins on phones, no word blur, no foreground parallax.

Responsive and accessible by design

Every scene has a desktop, a mobile and a reduced-motion branch. Phones get shorter pins and native scroll-snap instead of pinned horizontal scroll; the floor plan turns portrait; the custom cursor and magnetic buttons exist only for fine pointers. With reduced motion, scenes become still frames and Lenis isn't loaded at all.

Measured — lab, not field data

MetricDesktopPhone
Largest Contentful Paint3.5 s7.1 s
First Contentful Paint0.2 s1.6 s
Cumulative Layout Shift0.0000.003
JavaScript transferred233 KB233 KB
Total transferred1.5 MB0.6 MB
Requests2221

Playwright + Chromium (lab, cold cache, median of 3), 2026-10-04. Desktop: 1440×900, no throttling. Phone: 390×844, 4× CPU slowdown, 150 ms RTT / 1.6 Mbps. Images are hot-linked placeholders, so transfer sizes will change with real photography.

Reading the numbers

Layout is stable — CLS is effectively zero on both devices — and first paint is fast. Largest Contentful Paint is the outlier: even unthrottled it lands seconds after first paint, which points at the branded intro holding the hero image back rather than the network.

That is the clearest next optimisation: paint the hero image first and let the intro play over it.

06 / Result

Live — and honest about what's left.

A complete, reusable property experience: twenty sections, three device paths per scene, and a 3D studio that costs nothing until it's opened.

Open items

  1. 01Real photography

    The imagery is Unsplash placeholders; a real project needs its own photography, optimised locally, with credits.

  2. 02Viewing requests

    The booking form validates on client and server, but the API only logs requests — it still needs email or CRM delivery.

  3. 03Faster first view

    Let the hero image paint before the intro animation so Largest Contentful Paint isn't gated by it.

  4. 04Tests and field data

    No automated tests yet, and the numbers above are lab runs. Real-user Core Web Vitals, including INP, are the next measurement.