Private Office Hero

A split hero with a momentum-drag property carousel, map pins that follow the active listing and a recent-sales ticker.

  • Heroes
  • Real estate
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Drag uses Pointer Events; arrow keys step the carousel.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-realestate-split.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-realestate-split.tsx
1/**
2 * HeroRealestateSplit — a premium real-estate hero in a split layout. Left:
3 * editorial headline, a live price ticker of recent sales, and search. Right:
4 * a stylised SVG map with animated pins that drop in, pulse, and follow the
5 * selected listing, over a property-card carousel you can drag with momentum
6 * (spring-settled snapping) or step with arrow keys. Listing "photos" are
7 * procedural architecture renders (CSS gradients + SVG).
8 *
9 * Props: title, lede, listings ({ name, area, price, beds, baths, sqm, pin: [x%, y%], palette }[]),
10 * ticker ({ addr, price, delta }[]).
11 * CSS vars: --fd-est-paper, --fd-est-ink, --fd-est-accent.
12 *
13 * Browser support: all evergreen browsers. Pointer Events for drag.
14 * Motion: drag uses a velocity-tracking spring integrator (k=380, c=30) for
15 * the release snap; pins drop with --fd-spring; ticker is a CSS marquee;
16 * scroll via rAF + getBoundingClientRect (map parallax, transform only).
17 * prefers-reduced-motion: no marquee, instant snaps, static pins.
18 * Accessibility: carousel is a region with aria-roledescription, arrow-key
19 * navigation and live "n of N" status; prev/next are labelled buttons.
20 */
21import { useCallback, useEffect, useRef, useState } from "react";
22import type { KeyboardEvent, ReactNode } from "react";
23
24export type Listing = {
25 name: string;
26 area: string;
27 price: string;
28 beds: number;
29 baths: number;
30 sqm: number;
31 pin: [number, number];
32 palette: [string, string, string];
33 tag?: string;
34};
35export type Sale = { addr: string; price: string; delta: string };
36
37export interface HeroRealestateSplitProps {
38 eyebrow?: ReactNode;
39 title?: ReactNode;
40 lede?: ReactNode;
41 listings?: Listing[];
42 ticker?: Sale[];
43}
44
45const DEFAULT_LISTINGS: Listing[] = [
46 { name: "The Linden Residence", area: "Hampstead, NW3", price: "£4,250,000", beds: 5, baths: 4, sqm: 412, pin: [36, 30], palette: ["#E9DCC8", "#B88A5E", "#3B4A3F"], tag: "New" },
47 { name: "Wharf House No. 7", area: "Wapping, E1W", price: "£2,890,000", beds: 3, baths: 3, sqm: 238, pin: [70, 58], palette: ["#D6DEE4", "#7E8C99", "#2B3540"] },
48 { name: "Mews Cottage", area: "Notting Hill, W11", price: "£1,975,000", beds: 2, baths: 2, sqm: 146, pin: [22, 62], palette: ["#F1E4D8", "#C9826B", "#5B3A31"], tag: "Under offer" },
49 { name: "Garden Flat, Thurloe Sq.", area: "South Kensington, SW7", price: "£3,150,000", beds: 3, baths: 2, sqm: 201, pin: [44, 72], palette: ["#E3E7DA", "#8FA27E", "#36432F"] },
50];
51
52const DEFAULT_TICKER: Sale[] = [
53 { addr: "Cheyne Walk, SW3", price: "£6.4M", delta: "+3.1%" },
54 { addr: "Elgin Crescent, W11", price: "£3.85M", delta: "+1.8%" },
55 { addr: "Canonbury Sq, N1", price: "£2.2M", delta: "−0.6%" },
56 { addr: "Primrose Hill, NW1", price: "£4.95M", delta: "+2.4%" },
57 { addr: "Lansdowne Rd, W11", price: "£7.1M", delta: "+4.0%" },
58 { addr: "Albert Bridge Rd, SW11", price: "£1.9M", delta: "+0.9%" },
59];
60
61export function HeroRealestateSplit({
62 eyebrow = "Ashby & Vale · Private Office",
63 title = (
64 <>
65 Homes that rarely
66 <br />
67 <em>come to market.</em>
68 </>
69 ),
70 lede = "Four hundred off-market residences across prime London, shown by appointment and represented by the same partner from first viewing to completion.",
71 listings = DEFAULT_LISTINGS,
72 ticker = DEFAULT_TICKER,
73}: HeroRealestateSplitProps) {
74 const root = useRef<HTMLElement>(null);
75 const track = useRef<HTMLDivElement>(null);
76 const viewport = useRef<HTMLDivElement>(null);
77 const map = useRef<HTMLDivElement>(null);
78 const [idx, setIdx] = useState(0);
79 const st = useRef({ x: 0, v: 0, target: 0, dragging: false, startX: 0, startPos: 0, lastX: 0, lastT: 0, raf: 0, step: 0 });
80 const reduced = useRef(false);
81
82 const apply = () => {
83 if (track.current) track.current.style.transform = `translate3d(${st.current.x.toFixed(2)}px,0,0)`;
84 };
85
86 const measure = () => {
87 const first = track.current?.children[0] as HTMLElement | undefined;
88 const gap = track.current ? parseFloat(getComputedStyle(track.current).columnGap || "16") : 16;
89 st.current.step = first ? first.offsetWidth + gap : 300;
90 };
91
92 const run = useCallback(() => {
93 const s = st.current;
94 if (s.raf) return;
95 let last = performance.now();
96 const tick = (now: number) => {
97 const dt = Math.min((now - last) / 1000, 0.032);
98 last = now;
99 if (!s.dragging) {
100 for (let i = 0; i < 2; i++) {
101 const h = dt / 2;
102 s.v += (-380 * (s.x - s.target) - 30 * s.v) * h;
103 s.x += s.v * h;
104 }
105 }
106 apply();
107 if (!s.dragging && Math.abs(s.x - s.target) < 0.3 && Math.abs(s.v) < 0.3) {
108 s.x = s.target;
109 s.v = 0;
110 apply();
111 s.raf = 0;
112 return;
113 }
114 s.raf = requestAnimationFrame(tick);
115 };
116 s.raf = requestAnimationFrame(tick);
117 }, []);
118
119 const go = useCallback(
120 (i: number, vel = 0) => {
121 const n = Math.max(0, Math.min(listings.length - 1, i));
122 measure();
123 st.current.target = -n * st.current.step;
124 st.current.v = vel;
125 setIdx(n);
126 if (reduced.current) {
127 st.current.x = st.current.target;
128 apply();
129 } else run();
130 },
131 [listings.length, run],
132 );
133
134 useEffect(() => {
135 reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
136 measure();
137 const ro = new ResizeObserver(() => {
138 measure();
139 st.current.target = -idx * st.current.step;
140 st.current.x = st.current.target;
141 apply();
142 });
143 if (viewport.current) ro.observe(viewport.current);
144 return () => {
145 ro.disconnect();
146 cancelAnimationFrame(st.current.raf);
147 st.current.raf = 0;
148 };
149 // eslint-disable-next-line react-hooks/exhaustive-deps
150 }, [idx]);
151
152 // drag
153 useEffect(() => {
154 const vp = viewport.current;
155 if (!vp) return;
156 const s = st.current;
157 const down = (e: PointerEvent) => {
158 if (e.button !== 0 || (e.target as HTMLElement).closest("button,a")) return;
159 measure();
160 s.dragging = true;
161 s.startX = e.clientX;
162 s.startPos = s.x;
163 s.lastX = e.clientX;
164 s.lastT = performance.now();
165 s.v = 0;
166 vp.setPointerCapture(e.pointerId);
167 vp.setAttribute("data-drag", "");
168 run();
169 };
170 const move = (e: PointerEvent) => {
171 if (!s.dragging) return;
172 const now = performance.now();
173 const dx = e.clientX - s.startX;
174 const min = -(listings.length - 1) * s.step;
175 let x = s.startPos + dx;
176 if (x > 0) x = x * 0.35;
177 if (x < min) x = min + (x - min) * 0.35;
178 s.x = x;
179 const dt = Math.max(1, now - s.lastT);
180 s.v = s.v * 0.6 + ((e.clientX - s.lastX) / dt) * 1000 * 0.4;
181 s.lastX = e.clientX;
182 s.lastT = now;
183 };
184 const up = (e: PointerEvent) => {
185 if (!s.dragging) return;
186 s.dragging = false;
187 vp.removeAttribute("data-drag");
188 if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
189 // momentum: project where the throw would land
190 const projected = s.x + s.v * 0.18;
191 const n = Math.round(-projected / s.step);
192 go(n, s.v);
193 };
194 vp.addEventListener("pointerdown", down);
195 vp.addEventListener("pointermove", move);
196 vp.addEventListener("pointerup", up);
197 vp.addEventListener("pointercancel", up);
198 return () => {
199 vp.removeEventListener("pointerdown", down);
200 vp.removeEventListener("pointermove", move);
201 vp.removeEventListener("pointerup", up);
202 vp.removeEventListener("pointercancel", up);
203 };
204 }, [go, listings.length, run]);
205
206 // scroll parallax on map
207 useEffect(() => {
208 const el = root.current;
209 if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
210 let raf = 0;
211 let visible = true;
212 const frame = () => {
213 raf = 0;
214 const r = el.getBoundingClientRect();
215 const p = Math.min(1, Math.max(0, -r.top / Math.max(1, r.height)));
216 if (map.current) map.current.style.transform = `translate3d(0, ${(p * 120).toFixed(1)}px, 0) scale(${(1.04 + p * 0.1).toFixed(4)})`;
217 if (visible && !document.hidden) raf = requestAnimationFrame(frame);
218 };
219 const start = () => {
220 if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame);
221 };
222 const io = new IntersectionObserver(([e]) => {
223 visible = e.isIntersecting;
224 if (visible) start();
225 });
226 io.observe(el);
227 document.addEventListener("visibilitychange", start);
228 start();
229 return () => {
230 cancelAnimationFrame(raf);
231 io.disconnect();
232 document.removeEventListener("visibilitychange", start);
233 };
234 }, []);
235
236 const onKey = (e: KeyboardEvent) => {
237 if (e.key === "ArrowRight") {
238 e.preventDefault();
239 go(idx + 1);
240 } else if (e.key === "ArrowLeft") {
241 e.preventDefault();
242 go(idx - 1);
243 }
244 };
245
246 const cur = listings[idx];
247
248 return (
249 <section ref={root} className="fd-est" aria-label="Ashby & Vale listings">
250 <style>{css}</style>
251 <div className="fd-est-left">
252 <p className="fd-est-eyebrow">{eyebrow}</p>
253 <h1 className="fd-est-h1">{title}</h1>
254 <p className="fd-est-lede">{lede}</p>
255 <form className="fd-est-search" role="search" onSubmit={(e) => e.preventDefault()}>
256 <label className="fd-est-sf">
257 <span>Neighbourhood</span>
258 <input defaultValue="Hampstead, Notting Hill…" aria-label="Neighbourhood" />
259 </label>
260 <label className="fd-est-sf fd-est-sf-b">
261 <span>Budget</span>
262 <input defaultValue="£2M – £5M" aria-label="Budget" />
263 </label>
264 <button type="submit" className="fd-est-go">
265 Search
266 </button>
267 </form>
268 <div className="fd-est-ticker" aria-label="Recent sales">
269 <span className="fd-est-tk-l">
270 <i aria-hidden="true" />
271 Sold this week
272 </span>
273 <div className="fd-est-tk-vp">
274 <ul className="fd-est-tk">
275 {[...ticker, ...ticker].map((t, i) => (
276 <li key={i} aria-hidden={i >= ticker.length || undefined}>
277 <span>{t.addr}</span>
278 <b>{t.price}</b>
279 <em className={t.delta.startsWith("−") ? "is-down" : ""}>{t.delta}</em>
280 </li>
281 ))}
282 </ul>
283 </div>
284 </div>
285 </div>
286
287 <div className="fd-est-right">
288 <div ref={map} className="fd-est-map" aria-hidden="true">
289 <svg viewBox="0 0 600 700" preserveAspectRatio="xMidYMid slice">
290 <rect width="600" height="700" fill="#E9E5DC" />
291 {/* parks */}
292 <path d="M120 120 C180 90 260 110 280 170 C300 230 230 270 170 250 C110 230 80 160 120 120Z" fill="#CFD8C0" />
293 <path d="M380 420 C430 400 500 420 510 470 C520 520 460 540 410 520 C370 500 350 440 380 420Z" fill="#CFD8C0" />
294 <path d="M60 470 C100 450 150 470 150 510 C150 550 100 560 70 540 C40 520 30 490 60 470Z" fill="#D5DCC6" />
295 {/* river */}
296 <path d="M-20 560 C80 520 160 600 260 560 C360 520 420 600 520 580 C580 568 620 540 640 530 L640 620 C600 640 560 660 500 664 C400 670 340 610 250 640 C160 670 80 610 -20 650Z" fill="#B9CCD6" />
297 {/* streets */}
298 <g stroke="#FBF9F4" strokeLinecap="round" fill="none">
299 <path d="M0 300 C150 280 300 320 600 260" strokeWidth="10" />
300 <path d="M260 0 C250 200 300 400 280 700" strokeWidth="10" />
301 <path d="M0 160 L600 220" strokeWidth="6" />
302 <path d="M90 0 L180 700" strokeWidth="6" />
303 <path d="M450 0 C420 200 470 380 430 700" strokeWidth="6" />
304 <path d="M0 420 L600 380" strokeWidth="6" />
305 {Array.from({ length: 12 }, (_, i) => (
306 <path key={i} d={`M${i * 55 - 20} 0 L${i * 55 + 60} 700`} strokeWidth="2" opacity="0.7" />
307 ))}
308 {Array.from({ length: 14 }, (_, i) => (
309 <path key={`h${i}`} d={`M0 ${i * 52} L600 ${i * 52 + 30}`} strokeWidth="2" opacity="0.7" />
310 ))}
311 </g>
312 </svg>
313 {listings.map((l, i) => (
314 <span
315 key={l.name}
316 className={`fd-est-pin ${i === idx ? "is-on" : ""}`}
317 style={{ left: `${l.pin[0]}%`, top: `${l.pin[1]}%`, animationDelay: `${500 + i * 140}ms` }}
318 >
319 <b>{shortPrice(l.price)}</b>
320 </span>
321 ))}
322 </div>
323 <div className="fd-est-mapshade" aria-hidden="true" />
324
325 <div
326 className="fd-est-car"
327 role="region"
328 aria-roledescription="carousel"
329 aria-label="Featured residences"
330 tabIndex={0}
331 onKeyDown={onKey}
332 >
333 <div ref={viewport} className="fd-est-vp">
334 <div ref={track} className="fd-est-track">
335 {listings.map((l, i) => (
336 <article key={l.name} className={`fd-est-card ${i === idx ? "is-on" : ""}`} aria-roledescription="slide" aria-label={`${i + 1} of ${listings.length}: ${l.name}`}>
337 <div className="fd-est-photo" style={{ ["--a" as string]: l.palette[0], ["--b" as string]: l.palette[1], ["--c" as string]: l.palette[2] }}>
338 <Facade palette={l.palette} variant={i} />
339 {l.tag && <span className="fd-est-tag">{l.tag}</span>}
340 </div>
341 <div className="fd-est-meta">
342 <div>
343 <h2>{l.name}</h2>
344 <p>{l.area}</p>
345 </div>
346 <b>{l.price}</b>
347 </div>
348 <ul className="fd-est-facts">
349 <li>{l.beds} bed</li>
350 <li>{l.baths} bath</li>
351 <li>{l.sqm} m²</li>
352 </ul>
353 </article>
354 ))}
355 </div>
356 </div>
357 <div className="fd-est-ctrl">
358 <span className="fd-est-status" aria-live="polite">
359 <b>{String(idx + 1).padStart(2, "0")}</b> / {String(listings.length).padStart(2, "0")} · {cur.area}
360 </span>
361 <div className="fd-est-arrows">
362 <button type="button" aria-label="Previous residence" onClick={() => go(idx - 1)} disabled={idx === 0}>
363 <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M10 3.5 5.5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
364 </button>
365 <button type="button" aria-label="Next residence" onClick={() => go(idx + 1)} disabled={idx === listings.length - 1}>
366 <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
367 </button>
368 </div>
369 </div>
370 </div>
371 </div>
372 </section>
373 );
374}
375
376function shortPrice(p: string) {
377 const n = Number(p.replace(/[^0-9.]/g, ""));
378 const cur = p.replace(/[0-9.,\s]/g, "");
379 if (!n) return p;
380 if (n >= 1e6) return `${cur}${(n / 1e6).toFixed(2).replace(/\.?0+$/, "")}M`;
381 return `${cur}${Math.round(n / 1e3)}k`;
382}
383
384/** A procedural "architectural photo": sky, facade, windows, foliage. */
385function Facade({ palette, variant }: { palette: [string, string, string]; variant: number }) {
386 const [a, b, c] = palette;
387 const v = variant % 4;
388 return (
389 <svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" className="fd-est-svg">
390 <rect width="320" height="200" fill={a} />
391 <rect width="320" height="120" fill="#fff" opacity="0.35" />
392 <circle cx={v % 2 ? 260 : 60} cy="40" r="60" fill="#fff" opacity="0.35" />
393 {v === 0 && (
394 <g>
395 <path d="M70 70 L160 30 L250 70 L250 180 L70 180Z" fill={b} />
396 <path d="M60 72 L160 26 L260 72" fill="none" stroke={c} strokeWidth="6" />
397 {[95, 145, 195].map((x) => <rect key={x} x={x} y="90" width="30" height="40" fill={c} opacity="0.85" />)}
398 {[95, 195].map((x) => <rect key={x} x={x} y="140" width="30" height="40" fill={c} opacity="0.85" />)}
399 <rect x="145" y="140" width="30" height="40" fill="#1a1a1a" />
400 </g>
401 )}
402 {v === 1 && (
403 <g>
404 <rect x="40" y="40" width="240" height="140" fill={b} />
405 {Array.from({ length: 4 }, (_, r) => Array.from({ length: 6 }, (_, k) => (
406 <rect key={`${r}-${k}`} x={54 + k * 38} y={52 + r * 32} width="26" height="22" fill={c} opacity={0.6 + ((r + k) % 3) * 0.12} />
407 )))}
408 <rect x="40" y="36" width="240" height="6" fill={c} />
409 </g>
410 )}
411 {v === 2 && (
412 <g>
413 <rect x="30" y="60" width="120" height="120" fill={b} />
414 <rect x="150" y="80" width="140" height="100" fill={a} stroke={b} strokeWidth="2" />
415 <path d="M30 60 L90 30 L150 60Z" fill={c} />
416 <rect x="60" y="110" width="28" height="70" fill={c} />
417 <rect x="100" y="90" width="30" height="30" fill="#fff" opacity="0.7" />
418 {[170, 220].map((x) => <rect key={x} x={x} y="100" width="40" height="50" fill={c} opacity="0.5" />)}
419 </g>
420 )}
421 {v === 3 && (
422 <g>
423 <rect x="50" y="50" width="220" height="130" fill="#F7F3EC" />
424 {[70, 130, 190].map((x) => <rect key={x} x={x} y="70" width="40" height="80" fill={c} opacity="0.7" />)}
425 <rect x="50" y="150" width="220" height="6" fill={b} />
426 {Array.from({ length: 10 }, (_, i) => <rect key={i} x={54 + i * 22} y="156" width="3" height="24" fill={b} />)}
427 </g>
428 )}
429 <ellipse cx="20" cy="185" rx="60" ry="34" fill={c} opacity="0.85" />
430 <ellipse cx="300" cy="190" rx="50" ry="30" fill={c} opacity="0.75" />
431 <rect y="180" width="320" height="20" fill={c} opacity="0.25" />
432 </svg>
433 );
434}
435
436const css = `
437.fd-est, .fd-est *, .fd-est *::before, .fd-est *::after { box-sizing: border-box; }
438.fd-est {
439 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
440 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
441 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
442 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
443 --fd-est-paper: #F6F3EE;
444 --fd-est-ink: #17150F;
445 --fd-est-muted: #6D675C;
446 --fd-est-accent: #1F3B2D;
447 --fd-est-serif: ui-serif, "New York", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
448 --fd-est-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
449 position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); margin: 0; padding: 0; min-height: 860px;
450 background: var(--fd-est-paper); color: var(--fd-est-ink); font: 400 1rem/1.5 var(--fd-est-sans); isolation: isolate; overflow: hidden;
451}
452.fd-est-left { display: flex; flex-direction: column; justify-content: center; padding: 120px 56px 48px max(40px, calc(50vw - 620px)); min-width: 0; }
453.fd-est-eyebrow { margin: 0 0 22px; padding: 0; font: 600 0.75rem/1 var(--fd-est-sans); letter-spacing: 0.2em; text-transform: uppercase; color: var(--fd-est-accent); animation: fd-est-rise var(--fd-dur-4) var(--fd-ease-out) 100ms both; }
454.fd-est-h1 { margin: 0; padding: 0; font: 400 clamp(2.75rem, 5vw, 4.75rem)/1 var(--fd-est-serif); letter-spacing: -0.035em; color: var(--fd-est-ink); text-wrap: balance; animation: fd-est-mask 1100ms var(--fd-ease-out) 150ms both; }
455.fd-est-h1 em { font-style: italic; color: var(--fd-est-accent); }
456.fd-est-lede { margin: 24px 0 0; padding: 0; max-width: 30rem; font: 400 1.0625rem/1.6 var(--fd-est-sans); color: var(--fd-est-muted); letter-spacing: -0.008em; text-wrap: pretty; animation: fd-est-rise var(--fd-dur-4) var(--fd-ease-out) 350ms both; }
457.fd-est-search { display: grid; grid-template-columns: 1.4fr 1fr auto; gap: 0; max-width: 520px; margin: 34px 0 0; padding: 6px; border-radius: 9999px; background: #fff; box-shadow: 0 0 0 1px rgba(23,21,15,0.08), 0 14px 30px -18px rgba(23,21,15,0.3); animation: fd-est-rise var(--fd-dur-4) var(--fd-ease-out) 450ms both; }
458.fd-est-sf { display: flex; flex-direction: column; justify-content: center; gap: 3px; margin: 0; padding: 6px 18px; min-width: 0; }
459.fd-est-sf-b { border-left: 1px solid rgba(23,21,15,0.1); }
460.fd-est-sf span { font: 600 0.625rem/1 var(--fd-est-sans); letter-spacing: 0.12em; text-transform: uppercase; color: var(--fd-est-muted); }
461.fd-est-sf input { appearance: none; -webkit-appearance: none; width: 100%; margin: 0; padding: 0; border: 0; outline: 0; background: transparent; font: 500 0.9375rem/1.3 var(--fd-est-sans); color: var(--fd-est-ink); min-width: 0; }
462.fd-est-go { appearance: none; -webkit-appearance: none; cursor: pointer; margin: 0; padding: 0 24px; height: 50px; border: 0; border-radius: 9999px; background: var(--fd-est-accent); color: #F6F3EE; font: 500 0.9375rem/1 var(--fd-est-sans); transition: transform var(--fd-dur-2) var(--fd-spring), background var(--fd-dur-1); }
463.fd-est-go:hover { background: #2A4D3B; }
464.fd-est-go:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
465.fd-est-go:focus-visible, .fd-est-car:focus-visible, .fd-est-arrows button:focus-visible { outline: 2px solid rgba(31,59,45,0.7); outline-offset: 2px; }
466.fd-est-search:focus-within { box-shadow: 0 0 0 2px rgba(31,59,45,0.4), 0 14px 30px -18px rgba(23,21,15,0.3); }
467
468.fd-est-ticker { display: flex; align-items: center; gap: 16px; margin: 48px 0 0; padding: 16px 0 0; border-top: 1px solid rgba(23,21,15,0.1); min-width: 0; animation: fd-est-rise var(--fd-dur-4) var(--fd-ease-out) 600ms both; }
469.fd-est-tk-l { display: inline-flex; align-items: center; gap: 8px; flex: none; font: 600 0.6875rem/1 var(--fd-est-sans); letter-spacing: 0.12em; text-transform: uppercase; color: var(--fd-est-ink); }
470.fd-est-tk-l i { display: block; width: 6px; height: 6px; border-radius: 9999px; background: #C2410C; animation: fd-est-blink 1.6s var(--fd-ease-in-out) infinite; }
471.fd-est-tk-vp { overflow: hidden; flex: 1; min-width: 0; -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
472.fd-est-tk { display: flex; gap: 28px; width: max-content; margin: 0; padding: 0; list-style: none; animation: fd-est-marquee 36s linear infinite; }
473.fd-est-ticker:hover .fd-est-tk { animation-play-state: paused; }
474.fd-est-tk li { display: inline-flex; align-items: baseline; gap: 8px; margin: 0; padding: 0; white-space: nowrap; font: 400 0.8125rem/1 var(--fd-est-sans); color: var(--fd-est-muted); }
475.fd-est-tk b { font: 600 0.875rem/1 var(--fd-est-sans); color: var(--fd-est-ink); font-variant-numeric: tabular-nums; }
476.fd-est-tk em { font: 600 0.75rem/1 var(--fd-est-sans); font-style: normal; color: #15803D; }
477.fd-est-tk em.is-down { color: #B91C1C; }
478
479.fd-est-right { position: relative; min-height: 860px; overflow: hidden; clip-path: inset(0 0 0 100%); animation: fd-est-wipe 1300ms var(--fd-ease-in-out) 100ms both; }
480.fd-est-map { position: absolute; inset: -6% 0 -6% 0; will-change: transform; transform: scale(1.04); }
481.fd-est-map svg { display: block; width: 100%; height: 100%; }
482.fd-est-mapshade { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(246,243,238,0) 45%, rgba(246,243,238,0.65) 100%), linear-gradient(90deg, rgba(246,243,238,0.5), transparent 18%); }
483.fd-est-pin {
484 position: absolute; z-index: 1; display: block; margin: 0; transform: translate(-50%, -100%); animation: fd-est-drop 800ms var(--fd-spring) both;
485}
486.fd-est-pin b {
487 position: relative; display: block; padding: 7px 11px; border-radius: 9999px; background: #fff; color: var(--fd-est-ink); white-space: nowrap;
488 font: 600 0.75rem/1 var(--fd-est-sans); box-shadow: 0 6px 16px -6px rgba(23,21,15,0.4), 0 0 0 1px rgba(23,21,15,0.06);
489 transition: background var(--fd-dur-3) var(--fd-ease-out), color var(--fd-dur-3) var(--fd-ease-out), transform var(--fd-dur-3) var(--fd-spring);
490}
491.fd-est-pin b::after { content: ""; position: absolute; left: 50%; bottom: -5px; width: 10px; height: 10px; margin-left: -5px; background: inherit; transform: rotate(45deg); border-radius: 0 0 2px 0; }
492.fd-est-pin::after { content: ""; position: absolute; left: 50%; top: calc(100% + 6px); width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 9999px; background: rgba(31,59,45,0.35); opacity: 0; }
493.fd-est-pin.is-on { z-index: 2; }
494.fd-est-pin.is-on b { background: var(--fd-est-accent); color: #F6F3EE; transform: scale(1.12) translateY(-4px); }
495.fd-est-pin.is-on::after { opacity: 1; animation: fd-est-pulse 2s var(--fd-ease-out) infinite; }
496
497.fd-est-car { position: absolute; left: 32px; right: 0; bottom: 36px; outline: none; animation: fd-est-rise 1000ms var(--fd-ease-out) 800ms both; }
498.fd-est-vp { overflow: hidden; padding: 20px 0 24px 0; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
499.fd-est-vp[data-drag] { cursor: grabbing; }
500.fd-est-track { display: flex; column-gap: 16px; will-change: transform; }
501.fd-est-card {
502 flex: none; width: 300px; margin: 0; padding: 10px 10px 14px; border-radius: 22px; background: #fff;
503 box-shadow: 0 0 0 1px rgba(23,21,15,0.06), 0 20px 40px -24px rgba(23,21,15,0.35);
504 opacity: 0.72; transform: scale(0.95); transform-origin: left bottom;
505 transition: opacity var(--fd-dur-3) var(--fd-ease-out), transform var(--fd-dur-4) var(--fd-spring), box-shadow var(--fd-dur-3);
506}
507.fd-est-card.is-on { opacity: 1; transform: none; box-shadow: 0 0 0 1px rgba(23,21,15,0.06), 0 30px 60px -24px rgba(23,21,15,0.45); }
508.fd-est-photo { position: relative; overflow: hidden; height: 168px; border-radius: 14px; background: var(--a); }
509.fd-est-svg { display: block; width: 100%; height: 100%; pointer-events: none; }
510.fd-est-tag { position: absolute; left: 10px; top: 10px; padding: 5px 9px; border-radius: 9999px; background: rgba(255,255,255,0.9); color: var(--fd-est-ink); font: 600 0.6875rem/1 var(--fd-est-sans); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
511.fd-est-meta { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin: 12px 4px 0; }
512.fd-est-meta h2 { margin: 0; padding: 0; font: 400 1.125rem/1.2 var(--fd-est-serif); letter-spacing: -0.01em; color: var(--fd-est-ink); }
513.fd-est-meta p { margin: 3px 0 0; padding: 0; font: 400 0.8125rem/1.2 var(--fd-est-sans); color: var(--fd-est-muted); }
514.fd-est-meta b { flex: none; font: 600 0.9375rem/1.3 var(--fd-est-sans); color: var(--fd-est-ink); font-variant-numeric: tabular-nums; }
515.fd-est-facts { display: flex; gap: 6px; margin: 12px 4px 0; padding: 0; list-style: none; }
516.fd-est-facts li { margin: 0; padding: 5px 9px; border-radius: 9999px; background: #F3F0EA; font: 500 0.75rem/1 var(--fd-est-sans); color: var(--fd-est-muted); }
517.fd-est-ctrl { display: flex; justify-content: space-between; align-items: center; margin: 0; padding: 0 32px 0 0; }
518.fd-est-status { font: 400 0.8125rem/1 var(--fd-est-sans); color: var(--fd-est-muted); }
519.fd-est-status b { font: 600 0.8125rem/1 var(--fd-est-sans); color: var(--fd-est-ink); font-variant-numeric: tabular-nums; }
520.fd-est-arrows { display: flex; gap: 8px; }
521.fd-est-arrows button { appearance: none; -webkit-appearance: none; cursor: pointer; display: grid; place-items: center; width: 42px; height: 42px; margin: 0; padding: 0; border-radius: 9999px; border: 0; background: #fff; color: var(--fd-est-ink); box-shadow: 0 0 0 1px rgba(23,21,15,0.1); transition: transform var(--fd-dur-2) var(--fd-spring), opacity var(--fd-dur-2); }
522.fd-est-arrows button svg { display: block; }
523.fd-est-arrows button:active { transform: scale(0.92); transition-duration: var(--fd-dur-1); }
524.fd-est-arrows button:disabled { opacity: 0.35; cursor: default; }
525
526@keyframes fd-est-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
527@keyframes fd-est-mask { from { opacity: 0; clip-path: inset(0 0 100% 0); transform: translateY(24px); } to { opacity: 1; clip-path: inset(0 0 -10% 0); transform: none; } }
528@keyframes fd-est-wipe { to { clip-path: inset(0 0 0 0); } }
529@keyframes fd-est-drop { from { opacity: 0; transform: translate(-50%, -260%); } to { opacity: 1; transform: translate(-50%, -100%); } }
530@keyframes fd-est-pulse { 0% { transform: scale(0.6); opacity: 0.8; } 100% { transform: scale(3.2); opacity: 0; } }
531@keyframes fd-est-marquee { to { transform: translateX(-50%); } }
532@keyframes fd-est-blink { 50% { opacity: 0.3; } }
533
534@media (max-width: 900px) {
535 .fd-est { grid-template-columns: 1fr; min-height: 844px; }
536 .fd-est-left { padding: 96px 20px 24px; }
537 .fd-est-lede { font-size: 0.9375rem; margin-top: 16px; }
538 .fd-est-search { grid-template-columns: 1fr auto; margin-top: 24px; }
539 .fd-est-sf-b { display: none; }
540 .fd-est-ticker { margin-top: 24px; }
541 .fd-est-right { min-height: 460px; }
542 .fd-est-car { left: 16px; bottom: 16px; }
543 .fd-est-card { width: 260px; }
544 .fd-est-pin { font-size: 0.75rem; }
545 .fd-est-map { inset: -6% 0 30% 0; }
546 .fd-est-photo { height: 130px; }
547 .fd-est-ctrl { padding-right: 16px; }
548}
549@media (prefers-reduced-motion: reduce) {
550 .fd-est *, .fd-est *::before, .fd-est *::after, .fd-est-right { animation: none !important; }
551 .fd-est-right { clip-path: none; }
552}
553@media (prefers-reduced-transparency: reduce) { .fd-est-tag { background: #fff; -webkit-backdrop-filter: none; backdrop-filter: none; } }
554`;
555
556/* ───────────────────────── Demo ───────────────────────── */
557
558export function Demo() {
559 return (
560 <div className="fd-est-demo">
561 <style>{demoCss}</style>
562 <header className="fd-est-demo-nav">
563 <a className="fd-est-demo-logo" href="#top">
564 Ashby <i>&amp;</i> Vale
565 </a>
566 <nav aria-label="Primary">
567 <a href="#buy">Buy</a>
568 <a href="#sell">Sell</a>
569 <a href="#lettings">Lettings</a>
570 <a href="#journal">Journal</a>
571 </nav>
572 <a className="fd-est-demo-cta" href="#valuation">Book a valuation</a>
573 </header>
574 <HeroRealestateSplit />
575 </div>
576 );
577}
578
579const demoCss = `
580.fd-est-demo { position: relative; margin: 0; padding: 0; min-height: 100vh; background: #F6F3EE; }
581.fd-est-demo *, .fd-est-demo *::before, .fd-est-demo *::after { box-sizing: border-box; }
582.fd-est-demo-nav { position: absolute; z-index: 10; top: 0; left: 0; width: 50%; height: 76px; margin: 0; padding: 0 56px 0 max(40px, calc(50vw - 620px)); display: flex; align-items: center; justify-content: space-between; gap: 20px; }
583.fd-est-demo-nav .fd-est-demo-logo { margin: 0; color: #17150F; text-decoration: none; font: 400 1.375rem/1 ui-serif, "New York", "Iowan Old Style", Georgia, serif; letter-spacing: -0.02em; white-space: nowrap; }
584.fd-est-demo-logo i { font-style: italic; color: #1F3B2D; }
585.fd-est-demo-nav nav { display: flex; gap: 24px; }
586.fd-est-demo-nav a { color: #4A453B; text-decoration: none; font: 500 0.875rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; }
587.fd-est-demo-nav .fd-est-demo-cta { display: none; }
588.fd-est-demo-nav a:focus-visible { outline: 2px solid rgba(31,59,45,.7); outline-offset: 2px; border-radius: 6px; }
589@media (max-width: 1100px) { .fd-est-demo-nav nav { display: none; } }
590@media (max-width: 900px) { .fd-est-demo-nav { width: 100%; padding: 0 20px; height: 64px; } .fd-est-demo-nav .fd-est-demo-cta { display: inline-flex; align-items: center; height: 36px; padding: 0 14px; border-radius: 9999px; background: #1F3B2D; color: #F6F3EE; } }
591`;

More Heroes. Same file contract.

Ledger Card Hero

A photoreal CSS-3D metal card that tilts to the cursor, then flips and docks on scroll while spend stats count up.

Agent Terminal Hero

A terminal types a prompt and streams an agent's plan while the generated dashboard assembles itself beside it.

Maison Editorial Hero

A giant serif masthead and procedural duotone fabric frames revealed by clip-path wipes, drifting apart on scroll.