Fjord Parallax Hero

A layered SVG landscape with deep pointer and scroll parallax, a mirrored lake and golden hour fading to dusk.

  • Heroes
  • Travel & hospitality
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Booking bar uses backdrop-filter; water ripple uses an SVG displacement filter.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-travel-parallax.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-travel-parallax.tsx
1/**
2 * HeroTravelParallax — a boutique-hotel hero built from a layered SVG
3 * landscape: sky, sun, five mountain ranges, drifting mist and a lake that
4 * mirrors the ranges. Layers move at different depths with the pointer and on
5 * scroll, and the scene shifts from golden hour to blue dusk as the section
6 * scrolls away (the sun sets, stars appear in the reflection). A frosted
7 * booking bar sits on top.
8 *
9 * Props: brand, title, lede, location, defaults for the booking fields
10 * (checkIn, checkOut, guests), onSearch(values).
11 * CSS vars: --fd-alp-ink, --fd-alp-glass, --fd-alp-accent.
12 *
13 * Browser support: all evergreen browsers; the booking bar uses
14 * backdrop-filter and falls back to an opaque panel when unsupported or when
15 * prefers-reduced-transparency is set.
16 * Motion: pointer parallax on a spring (k=380, c=30), scroll-linked depth and
17 * colour via rAF + getBoundingClientRect (transform/opacity only, paused off
18 * screen). prefers-reduced-motion: static golden-hour composition.
19 * Accessibility: the landscape is decorative (aria-hidden). The booking bar is
20 * a real <form> with labelled inputs.
21 */
22import { useEffect, useId, useRef, useState } from "react";
23import type { FormEvent, ReactNode } from "react";
24
25export interface HeroTravelParallaxProps {
26 brand?: string;
27 kicker?: ReactNode;
28 title?: ReactNode;
29 lede?: ReactNode;
30 location?: string;
31 checkIn?: string;
32 checkOut?: string;
33 guests?: number;
34 onSearch?: (v: { location: string; checkIn: string; checkOut: string; guests: number }) => void;
35}
36
37// ranges from far to near: [color day, color dusk, path]
38const RANGES: { day: string; dusk: string; d: string; depth: number }[] = [
39 { day: "#c9a7a8", dusk: "#5d5f8a", depth: 0.08, d: "M0 330 L90 290 L170 312 L260 250 L330 280 L420 220 L520 268 L600 240 L700 284 L790 236 L880 270 L960 228 L1060 262 L1160 216 L1260 258 L1360 238 L1440 270 L1440 600 L0 600Z" },
40 { day: "#a7848e", dusk: "#45486f", depth: 0.16, d: "M0 360 L120 300 L210 330 L300 262 L380 300 L470 240 L560 296 L650 270 L760 318 L860 254 L950 300 L1060 246 L1150 296 L1250 270 L1340 312 L1440 280 L1440 600 L0 600Z" },
41 { day: "#7d6177", dusk: "#30345a", depth: 0.28, d: "M0 400 L80 360 L170 380 L260 318 L330 352 L420 300 L500 344 L610 290 L700 360 L820 330 L900 368 L1010 308 L1100 352 L1200 322 L1320 372 L1440 334 L1440 600 L0 600Z" },
42 { day: "#4c3b52", dusk: "#1f2343", depth: 0.45, d: "M0 430 L110 380 L200 410 L310 352 L420 410 L530 372 L640 420 L760 380 L880 432 L980 394 L1080 430 L1210 370 L1320 414 L1440 388 L1440 600 L0 600Z" },
43 { day: "#251c2e", dusk: "#11142a", depth: 0.7, d: "M0 470 C120 452 210 430 330 446 C460 462 540 420 660 432 C780 444 860 470 980 456 C1110 440 1220 420 1440 452 L1440 600 L0 600Z" },
44];
45
46const lerpHex = (a: string, b: string, t: number) => {
47 const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
48 const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
49 return `rgb(${pa.map((v, i) => Math.round(v + (pb[i] - v) * t)).join(",")})`;
50};
51
52export function HeroTravelParallax({
53 brand = "Halvøy",
54 kicker = "Lofoten · 68°N · 22 rooms",
55 title = (
56 <>
57 Where the fjord
58 <br />
59 <em>holds the light.</em>
60 </>
61 ),
62 lede = "A timber-and-glass retreat on the water’s edge in Reine. Sauna on the jetty, kitchen by Ingrid Moe, and a sky that stays gold until midnight.",
63 location = "Halvøy Reine, Lofoten",
64 checkIn = "2027-06-18",
65 checkOut = "2027-06-22",
66 guests = 2,
67 onSearch,
68}: HeroTravelParallaxProps) {
69 const root = useRef<HTMLElement>(null);
70 const layers = useRef<(HTMLDivElement | null)[]>([]);
71 const refl = useRef<HTMLDivElement>(null);
72 const sky = useRef<HTMLDivElement>(null);
73 const sun = useRef<HTMLDivElement>(null);
74 const copy = useRef<HTMLDivElement>(null);
75 const stars = useRef<HTMLDivElement>(null);
76 const mist = useRef<(HTMLDivElement | null)[]>([]);
77 const fills = useRef<(SVGPathElement | null)[]>([]);
78 const rfills = useRef<(SVGPathElement | null)[]>([]);
79 const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
80 const [vals, setVals] = useState({ location, checkIn, checkOut, guests });
81 const [sent, setSent] = useState(false);
82
83 useEffect(() => {
84 const el = root.current;
85 if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
86 const sp = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0 };
87 const onMove = (e: PointerEvent) => {
88 if (e.pointerType === "touch") return;
89 const r = el.getBoundingClientRect();
90 sp.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
91 sp.ty = ((e.clientY - r.top) / r.height) * 2 - 1;
92 };
93 const onLeave = () => {
94 sp.tx = 0;
95 sp.ty = 0;
96 };
97 el.addEventListener("pointermove", onMove);
98 el.addEventListener("pointerleave", onLeave);
99 let raf = 0;
100 let last = performance.now();
101 let visible = true;
102 let lastP = -1;
103 let lastSig = "";
104 const frame = (now: number) => {
105 raf = 0;
106 const dt = Math.min((now - last) / 1000, 0.032);
107 last = now;
108 for (let i = 0; i < 2; i++) {
109 const h = dt / 2;
110 // softer spring for scenery (k=380, c=30 scaled by mass 4)
111 sp.vx += ((-380 * (sp.x - sp.tx) - 30 * sp.vx) / 4) * h;
112 sp.vy += ((-380 * (sp.y - sp.ty) - 30 * sp.vy) / 4) * h;
113 sp.x += sp.vx * h;
114 sp.y += sp.vy * h;
115 }
116 const r = el.getBoundingClientRect();
117 const p = Math.min(1, Math.max(0, -r.top / Math.max(1, r.height * 0.8)));
118 // idle: nothing moved since the last frame → skip all writes
119 const sig = `${sp.x.toFixed(4)}|${sp.y.toFixed(4)}|${p.toFixed(4)}`;
120 if (sig === lastSig) {
121 if (visible && !document.hidden) raf = requestAnimationFrame(frame);
122 return;
123 }
124 lastSig = sig;
125 layers.current.forEach((g, i) => {
126 if (!g) return;
127 const d = RANGES[i].depth;
128 g.style.transform = `translate3d(${(-sp.x * 60 * d).toFixed(2)}px, ${(-sp.y * 14 * d + p * 260 * d).toFixed(2)}px, 0)`;
129 });
130 if (refl.current) refl.current.style.transform = `translate3d(${(sp.x * 26).toFixed(2)}px, 0, 0)`;
131 if (mist.current[1]) mist.current[1].style.transform = `translate3d(${(-sp.x * 70).toFixed(1)}px, ${(p * 60).toFixed(1)}px, 0)`;
132 if (sun.current) sun.current.style.transform = `translate3d(${(-sp.x * 4).toFixed(2)}px, ${(p * 180 - sp.y * 3).toFixed(2)}px, 0)`;
133 if (copy.current) {
134 copy.current.style.transform = `translate3d(${(sp.x * -6).toFixed(2)}px, ${(p * -90).toFixed(1)}px, 0)`;
135 copy.current.style.opacity = `${(1 - p * 1.4).toFixed(3)}`;
136 }
137 const tp = Math.round(p * 200) / 200;
138 if (tp !== lastP) {
139 lastP = tp;
140 const dusk = Math.min(1, tp * 1.4);
141 if (sky.current) sky.current.style.setProperty("--dusk", dusk.toFixed(3));
142 fills.current.forEach((f, i) => f && f.setAttribute("fill", lerpHex(RANGES[i].day, RANGES[i].dusk, dusk)));
143 rfills.current.forEach((f, i) => f && f.setAttribute("fill", lerpHex(RANGES[i].day, RANGES[i].dusk, dusk)));
144 if (stars.current) stars.current.style.opacity = `${Math.max(0, dusk - 0.35) * 1.5}`;
145 }
146 if (visible && !document.hidden) raf = requestAnimationFrame(frame);
147 };
148 const start = () => {
149 if (!raf && visible && !document.hidden) {
150 last = performance.now();
151 raf = requestAnimationFrame(frame);
152 }
153 };
154 const io = new IntersectionObserver(([e]) => {
155 visible = e.isIntersecting;
156 if (visible) start();
157 });
158 io.observe(el);
159 document.addEventListener("visibilitychange", start);
160 start();
161 return () => {
162 cancelAnimationFrame(raf);
163 io.disconnect();
164 document.removeEventListener("visibilitychange", start);
165 el.removeEventListener("pointermove", onMove);
166 el.removeEventListener("pointerleave", onLeave);
167 };
168 }, []);
169
170 const submit = (e: FormEvent) => {
171 e.preventDefault();
172 onSearch?.(vals);
173 setSent(true);
174 window.setTimeout(() => setSent(false), 1800);
175 };
176
177 const nights = Math.max(1, Math.round((Date.parse(vals.checkOut) - Date.parse(vals.checkIn)) / 864e5) || 1);
178
179 return (
180 <section ref={root} className="fd-alp" aria-label={`${brand} — hotel`}>
181 <style>{css}</style>
182 <div ref={sky} className="fd-alp-sky" aria-hidden="true" />
183 <div className="fd-alp-scene" aria-hidden="true">
184 <svg className="fd-alp-defs" width="0" height="0" focusable="false">
185 <defs>
186 <radialGradient id={`${uid}-sun`}>
187 <stop offset="0" stopColor="#fff7e0" />
188 <stop offset="0.35" stopColor="#ffd59a" stopOpacity="0.9" />
189 <stop offset="1" stopColor="#ff9f6e" stopOpacity="0" />
190 </radialGradient>
191 <linearGradient id={`${uid}-water`} x1="0" x2="0" y1="0" y2="1">
192 <stop offset="0" stopColor="#000" stopOpacity="0" />
193 <stop offset="1" stopColor="#0a0c1c" stopOpacity="0.85" />
194 </linearGradient>
195 <radialGradient id={`${uid}-mist`}>
196 <stop offset="0" stopColor="#fff6ee" stopOpacity="0.62" />
197 <stop offset="0.45" stopColor="#fff6ee" stopOpacity="0.28" />
198 <stop offset="1" stopColor="#fff6ee" stopOpacity="0" />
199 </radialGradient>
200 <pattern id={`${uid}-ripple`} width="480" height="11" patternUnits="userSpaceOnUse">
201 <rect x="0" y="0" width="480" height="1" fill="#ffe8cc" opacity="0.10" />
202 <rect x="0" y="5" width="480" height="1" fill="#ffe8cc" opacity="0.05" />
203 </pattern>
204 <clipPath id={`${uid}-below`}>
205 <rect x="-400" y="500" width="2240" height="500" />
206 </clipPath>
207 </defs>
208 </svg>
209 <div ref={stars} className="fd-alp-layer fd-alp-static" style={{ opacity: 0 }}>
210 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice">
211 {STARS.map(([x, y, r], i) => (
212 <circle key={i} cx={x} cy={y} r={r} fill="#fff" />
213 ))}
214 </svg>
215 </div>
216 <div ref={sun} className="fd-alp-layer">
217 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice" className="fd-alp-halo">
218 <circle cx="980" cy="300" r="190" fill={`url(#${uid}-sun)`} />
219 </svg>
220 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice">
221 <circle cx="980" cy="300" r="44" fill="#fff4dc" />
222 </svg>
223 </div>
224 {RANGES.map((r, i) => (
225 <div key={i} ref={(n) => { layers.current[i] = n; }} className="fd-alp-layer">
226 {i === 4 && (
227 <div ref={(n) => { mist.current[0] = n; }} className="fd-alp-layer fd-alp-mistwrap">
228 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice" className="fd-alp-mist fd-alp-mist-a">
229 <ellipse cx="420" cy="420" rx="620" ry="46" fill={`url(#${uid}-mist)`} />
230 </svg>
231 </div>
232 )}
233 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice">
234 <path ref={(n) => { fills.current[i] = n; }} d={r.d} fill={r.day} />
235 {i === 4 && (
236 <g transform="translate(1010 404)">
237 {[0, 34, 70, 112].map((x, k) => (
238 <g key={k} transform={`translate(${x} ${k % 2 ? 6 : 0})`}>
239 <path d="M0 40 L0 14 L14 0 L28 14 L28 40Z" fill="#140f1c" />
240 <rect x="8" y="22" width="12" height="10" fill="#ffcf7d" />
241 </g>
242 ))}
243 <rect x="-30" y="40" width="200" height="3" fill="#140f1c" />
244 </g>
245 )}
246 </svg>
247 </div>
248 ))}
249 <div ref={(n) => { mist.current[1] = n; }} className="fd-alp-layer fd-alp-mistwrap">
250 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice" className="fd-alp-mist fd-alp-mist-b">
251 <ellipse cx="900" cy="474" rx="760" ry="38" fill={`url(#${uid}-mist)`} opacity=".85" />
252 </svg>
253 </div>
254 <div ref={refl} className="fd-alp-layer">
255 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice">
256 <rect x="-400" y="500" width="2240" height="500" fill="#2a2440" />
257 <g clipPath={`url(#${uid}-below)`} opacity="0.62">
258 <g transform="translate(0 1000) scale(1 -1)">
259 {RANGES.slice(1).map((r, i) => (
260 <path key={i} ref={(n) => { rfills.current[i + 1] = n; }} d={r.d} fill={r.day} />
261 ))}
262 </g>
263 <ellipse cx="980" cy="700" rx="40" ry="140" fill="#ffe2b0" opacity=".55" />
264 </g>
265 </svg>
266 </div>
267 <div className="fd-alp-layer fd-alp-ripple">
268 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice">
269 <rect x="-400" y="500" width="2240" height="400" fill={`url(#${uid}-ripple)`} />
270 </svg>
271 </div>
272 <div className="fd-alp-layer fd-alp-static">
273 <svg viewBox="0 -150 1440 900" preserveAspectRatio="xMidYMax slice">
274 <rect x="-400" y="500" width="2240" height="400" fill={`url(#${uid}-water)`} />
275 <rect x="-400" y="499" width="2240" height="2" fill="#ffe8c4" opacity=".35" />
276 </svg>
277 </div>
278 </div>
279 <div className="fd-alp-vignette" aria-hidden="true" />
280
281 <div ref={copy} className="fd-alp-copy">
282 <p className="fd-alp-kicker">{kicker}</p>
283 <h1 className="fd-alp-h1">{title}</h1>
284 <p className="fd-alp-lede">{lede}</p>
285 </div>
286
287 <form className="fd-alp-book" onSubmit={submit} aria-label="Check availability">
288 <label className="fd-alp-f fd-alp-f-loc">
289 <span>Where</span>
290 <input value={vals.location} onChange={(e) => setVals({ ...vals, location: e.target.value })} />
291 </label>
292 <label className="fd-alp-f">
293 <span>Check in</span>
294 <input type="date" value={vals.checkIn} onChange={(e) => setVals({ ...vals, checkIn: e.target.value })} />
295 </label>
296 <label className="fd-alp-f">
297 <span>Check out</span>
298 <input type="date" value={vals.checkOut} onChange={(e) => setVals({ ...vals, checkOut: e.target.value })} />
299 </label>
300 <div className="fd-alp-f fd-alp-f-g">
301 <span id={`${uid}-g`}>Guests</span>
302 <div className="fd-alp-step" role="group" aria-labelledby={`${uid}-g`}>
303 <button type="button" aria-label="Fewer guests" onClick={() => setVals({ ...vals, guests: Math.max(1, vals.guests - 1) })}>−</button>
304 <output aria-live="polite">{vals.guests}</output>
305 <button type="button" aria-label="More guests" onClick={() => setVals({ ...vals, guests: Math.min(6, vals.guests + 1) })}>+</button>
306 </div>
307 </div>
308 <button type="submit" className="fd-alp-go">
309 <span>{sent ? "Searching…" : "Check availability"}</span>
310 <small>
311 {nights} {nights === 1 ? "night" : "nights"} · from €{(420 * nights).toLocaleString("en-US")}
312 </small>
313 </button>
314 </form>
315 </section>
316 );
317}
318
319const STARS: [number, number, number][] = Array.from({ length: 70 }, (_, i) => {
320 const x = (i * 211.7) % 1440;
321 const y = ((i * 97.3) % 260) + 20;
322 return [Math.round(x), Math.round(y), i % 7 === 0 ? 1.6 : 0.9];
323});
324
325const css = `
326.fd-alp, .fd-alp *, .fd-alp *::before, .fd-alp *::after { box-sizing: border-box; }
327.fd-alp {
328 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
329 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
330 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
331 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
332 --fd-alp-ink: #1c1426;
333 --fd-alp-glass: rgba(255, 250, 244, 0.62);
334 --fd-alp-accent: #1c1426;
335 --fd-alp-serif: ui-serif, "New York", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
336 --fd-alp-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
337 position: relative; display: block; overflow: hidden; margin: 0; padding: 0; height: 100vh; min-height: 760px; max-height: 1000px;
338 background: #e8b9a0; color: #fff; font: 400 1rem/1.5 var(--fd-alp-sans); isolation: isolate;
339}
340.fd-alp-sky {
341 --dusk: 0;
342 position: absolute; inset: 0;
343 background: linear-gradient(180deg, #f6c9a8 0%, #f0b49a 30%, #e9a395 52%, #c98d97 70%);
344}
345.fd-alp-sky::after {
346 content: ""; position: absolute; inset: 0; opacity: var(--dusk);
347 background: linear-gradient(180deg, #0d1230 0%, #2b2d5e 40%, #6a5a8a 62%, #a77b8c 72%);
348}
349.fd-alp-scene { position: absolute; inset: 0; overflow: hidden; animation: fd-alp-scenein 1800ms var(--fd-ease-out) both; }
350.fd-alp-defs { position: absolute; width: 0; height: 0; overflow: hidden; display: block; }
351.fd-alp-layer { position: absolute; inset: -40px -80px; will-change: transform; pointer-events: none; }
352.fd-alp-layer > svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
353.fd-alp-mistwrap { inset: 0; will-change: auto; }
354.fd-alp-static { will-change: auto; }
355.fd-alp-mist { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
356.fd-alp-mist-a { animation: fd-alp-drift 38s var(--fd-ease-in-out) infinite alternate; }
357.fd-alp-mist-b { animation: fd-alp-drift 52s var(--fd-ease-in-out) -20s infinite alternate-reverse; }
358@keyframes fd-alp-drift { from { transform: translate3d(-8%, 0, 0); } to { transform: translate3d(10%, 0, 0); } }
359.fd-alp-halo { animation: fd-alp-pulse 8s var(--fd-ease-in-out) infinite alternate; transform-origin: 68% 33%; }
360.fd-alp-ripple { animation: fd-alp-ripple 9s var(--fd-ease-in-out) infinite alternate; }
361@keyframes fd-alp-ripple { from { transform: translate3d(-30px, 0, 0); } to { transform: translate3d(30px, 0, 0); } }
362.fd-alp-vignette { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(120% 80% at 30% 20%, transparent 50%, rgba(20,12,30,0.35)); }
363
364.fd-alp-copy { position: absolute; left: 0; right: 0; top: 0; margin: 0 auto; max-width: 1280px; padding: 15vh 56px 0; will-change: transform, opacity; }
365.fd-alp-kicker {
366 display: inline-flex; margin: 0 0 22px; padding: 0; color: rgba(36, 22, 40, 0.75);
367 font: 600 0.75rem/1 var(--fd-alp-sans); letter-spacing: 0.22em; text-transform: uppercase;
368 animation: fd-alp-rise var(--fd-dur-4) var(--fd-ease-out) 200ms both;
369}
370.fd-alp-h1 {
371 margin: 0; padding: 0; max-width: 11ch; color: #24162a;
372 font: 400 clamp(3rem, 6.6vw, 6rem)/0.94 var(--fd-alp-serif); letter-spacing: -0.035em; text-wrap: balance;
373 animation: fd-alp-blur 1200ms var(--fd-ease-out) 260ms both;
374}
375.fd-alp-h1 em { font-style: italic; color: #fff8ee; text-shadow: 0 2px 30px rgba(120, 50, 40, 0.35); }
376.fd-alp-lede {
377 margin: 22px 0 0; padding: 0; max-width: 31rem; color: rgba(36, 22, 40, 0.86);
378 font: 400 clamp(1rem, 1.2vw, 1.125rem)/1.55 var(--fd-alp-sans); letter-spacing: -0.01em; text-wrap: pretty;
379 animation: fd-alp-rise var(--fd-dur-4) var(--fd-ease-out) 520ms both;
380}
381
382.fd-alp-book {
383 position: absolute; left: 50%; bottom: 48px; z-index: 3; width: min(1080px, calc(100% - 48px)); margin: 0; padding: 10px;
384 display: grid; grid-template-columns: 1.5fr 1fr 1fr 0.9fr auto; gap: 4px; align-items: stretch;
385 border-radius: 28px; background: var(--fd-alp-glass); border: 1px solid rgba(255,255,255,0.55);
386 -webkit-backdrop-filter: blur(16px) saturate(1.4); backdrop-filter: blur(16px) saturate(1.4);
387 box-shadow: 0 1px 0 rgba(255,255,255,0.7) inset, 0 30px 80px -30px rgba(20, 10, 40, 0.55);
388 transform: translateX(-50%); animation: fd-alp-bar 1000ms var(--fd-ease-out) 700ms both;
389}
390.fd-alp-f { display: flex; flex-direction: column; justify-content: center; gap: 4px; margin: 0; padding: 10px 18px; border-radius: 18px; transition: background var(--fd-dur-1); min-width: 0; }
391.fd-alp-f:hover, .fd-alp-f:focus-within { background: rgba(255,255,255,0.55); }
392.fd-alp-f > span { display: block; margin: 0; font: 600 0.6875rem/1 var(--fd-alp-sans); letter-spacing: 0.08em; text-transform: uppercase; color: rgba(28,20,38,0.6); }
393.fd-alp-f input {
394 appearance: none; -webkit-appearance: none; display: block; width: 100%; margin: 0; padding: 0; border: 0; outline: 0; background: transparent;
395 font: 500 1rem/1.3 var(--fd-alp-sans); letter-spacing: -0.01em; color: var(--fd-alp-ink); min-width: 0;
396}
397.fd-alp-f input::-webkit-calendar-picker-indicator { opacity: 0.5; }
398.fd-alp-step { display: flex; align-items: center; gap: 12px; }
399.fd-alp-step button {
400 appearance: none; -webkit-appearance: none; display: grid; place-items: center; width: 26px; height: 26px; margin: 0; padding: 0; border-radius: 9999px; cursor: pointer;
401 border: 1px solid rgba(28,20,38,0.2); background: rgba(255,255,255,0.6); color: var(--fd-alp-ink); font: 500 0.9375rem/1 var(--fd-alp-sans);
402 transition: transform var(--fd-dur-2) var(--fd-spring);
403}
404.fd-alp-step button:active { transform: scale(0.9); transition-duration: var(--fd-dur-1); }
405.fd-alp-step output { min-width: 1ch; font: 500 1rem/1 var(--fd-alp-sans); color: var(--fd-alp-ink); font-variant-numeric: tabular-nums; }
406.fd-alp-go {
407 appearance: none; -webkit-appearance: none; cursor: pointer; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 4px;
408 margin: 0; padding: 0 26px; border: 0; border-radius: 20px; background: var(--fd-alp-accent); color: #fff7ee; text-align: left;
409 box-shadow: 0 10px 30px -10px rgba(28,20,38,0.7); transition: transform var(--fd-dur-2) var(--fd-spring), background var(--fd-dur-1);
410}
411.fd-alp-go span { font: 600 0.9375rem/1.1 var(--fd-alp-sans); letter-spacing: -0.01em; white-space: nowrap; }
412.fd-alp-go small { font: 400 0.75rem/1 var(--fd-alp-sans); color: rgba(255,247,238,0.7); white-space: nowrap; }
413.fd-alp-go:hover { background: #33243f; }
414.fd-alp-go:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
415.fd-alp-go:focus-visible, .fd-alp-step button:focus-visible { outline: 2px solid rgba(28,20,38,0.7); outline-offset: 2px; }
416
417@keyframes fd-alp-rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
418@keyframes fd-alp-blur { from { opacity: 0; filter: blur(16px); transform: translateY(20px); } to { opacity: 1; filter: none; transform: none; } }
419@keyframes fd-alp-bar { from { opacity: 0; transform: translate(-50%, 40px) scale(0.97); } to { opacity: 1; transform: translateX(-50%); } }
420@keyframes fd-alp-scenein { from { opacity: 0; transform: scale(1.08) translateY(30px); } to { opacity: 1; transform: none; } }
421@keyframes fd-alp-pulse { from { transform: scale(0.94); opacity: 0.85; } to { transform: scale(1.06); opacity: 1; } }
422
423@media (max-width: 860px) {
424 .fd-alp { min-height: 844px; height: auto; max-height: none; }
425 .fd-alp-copy { position: relative; padding: 110px 20px 0; }
426 .fd-alp-lede { font-size: 0.9375rem; margin-top: 18px; }
427 .fd-alp-book { position: absolute; bottom: 16px; width: calc(100% - 32px); grid-template-columns: 1fr 1fr; padding: 8px; border-radius: 24px; }
428 .fd-alp-f { padding: 9px 12px; }
429 .fd-alp-f-loc, .fd-alp-go { grid-column: 1 / -1; }
430 .fd-alp-go { min-height: 56px; flex-direction: row; justify-content: space-between; align-items: center; padding: 0 20px; border-radius: 16px; }
431}
432@media (prefers-reduced-motion: reduce) { .fd-alp *, .fd-alp *::before, .fd-alp-scene { animation: none !important; } }
433@media (prefers-reduced-transparency: reduce) { .fd-alp-book { background: #fbf4ec; -webkit-backdrop-filter: none; backdrop-filter: none; } }
434`;
435
436/* ───────────────────────── Demo ───────────────────────── */
437
438export function Demo() {
439 return (
440 <div className="fd-alp-demo">
441 <style>{demoCss}</style>
442 <header className="fd-alp-demo-nav">
443 <a className="fd-alp-demo-logo" href="#top">Halvøy</a>
444 <nav aria-label="Primary">
445 <a href="#rooms">Rooms</a>
446 <a href="#sauna">Sauna &amp; sea</a>
447 <a href="#kitchen">Kitchen</a>
448 <a href="#journeys">Journeys</a>
449 </nav>
450 <a className="fd-alp-demo-cta" href="#reserve">Reserve</a>
451 </header>
452 <HeroTravelParallax />
453 <section className="fd-alp-demo-after" aria-label="Rooms">
454 <p>Twenty-two rooms, each facing the water.</p>
455 <ul>
456 <li><b>Fjord Room</b><span>28 m² · from €420</span></li>
457 <li><b>Rorbu Cabin</b><span>46 m² · from €610</span></li>
458 <li><b>Jetty Suite</b><span>72 m² · from €980</span></li>
459 </ul>
460 </section>
461 </div>
462 );
463}
464
465const demoCss = `
466.fd-alp-demo { position: relative; margin: 0; padding: 0; min-height: 100vh; background: #1c1426; }
467.fd-alp-demo *, .fd-alp-demo *::before, .fd-alp-demo *::after { box-sizing: border-box; }
468.fd-alp-demo-nav {
469 position: absolute; z-index: 10; top: 0; left: 0; right: 0; max-width: 1280px; height: 76px; margin: 0 auto; padding: 0 56px;
470 display: flex; align-items: center; justify-content: space-between; gap: 20px;
471 animation: fd-alp-demo-in 900ms cubic-bezier(0.22, 1, 0.36, 1) 300ms both;
472}
473.fd-alp-demo-logo { margin: 0; color: #24162a; text-decoration: none; font: italic 400 1.625rem/1 ui-serif, "New York", "Iowan Old Style", Georgia, serif; letter-spacing: -0.02em; }
474.fd-alp-demo-nav nav { display: flex; gap: 32px; }
475.fd-alp-demo-nav nav a { color: rgba(36,22,42,0.85); text-decoration: none; font: 500 0.875rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; letter-spacing: -0.005em; }
476.fd-alp-demo-cta { display: inline-flex; align-items: center; height: 38px; padding: 0 18px; border-radius: 9999px; color: #24162a; text-decoration: none; font: 600 0.875rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; background: rgba(255,250,244,0.6); border: 1px solid rgba(255,255,255,0.6); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
477.fd-alp-demo-nav a:focus-visible { outline: 2px solid rgba(28,20,38,.7); outline-offset: 2px; border-radius: 8px; }
478.fd-alp-demo-after { margin: 0; padding: 96px 24px 140px; background: #11142a; color: #EDE7F2; text-align: center; }
479.fd-alp-demo-after p { margin: 0 auto; padding: 0; max-width: 20ch; font: 400 clamp(1.75rem, 3.4vw, 2.75rem)/1.1 ui-serif, "New York", "Iowan Old Style", Georgia, serif; letter-spacing: -0.02em; text-wrap: balance; }
480.fd-alp-demo-after ul { display: flex; flex-wrap: wrap; justify-content: center; gap: 16px; margin: 40px auto 0; padding: 0; list-style: none; }
481.fd-alp-demo-after li { display: flex; flex-direction: column; gap: 6px; min-width: 200px; margin: 0; padding: 20px 24px; border-radius: 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); text-align: left; }
482.fd-alp-demo-after b { font: 600 1rem/1.2 ui-sans-serif, -apple-system, "Inter", sans-serif; color: #fff; }
483.fd-alp-demo-after span { font: 400 0.875rem/1.2 ui-sans-serif, -apple-system, "Inter", sans-serif; color: rgba(237,231,242,0.6); }
484@keyframes fd-alp-demo-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
485@media (max-width: 860px) { .fd-alp-demo-nav { padding: 0 20px; height: 64px; } .fd-alp-demo-nav nav { display: none; } }
486@media (prefers-reduced-motion: reduce) { .fd-alp-demo-nav { animation: none; } }
487`;

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.