EV Scrollytelling Hero

A 300vh sticky scene: an EV drives through a tunnel of light while 0–100, range and charge specs count up in sequence.

  • Heroes
  • Automotive
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (position: sticky). The demo scrolls inside its frame — scroll to drive.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-auto-scrub.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-auto-scrub.tsx
1/**
2 * HeroAutoScrub — Apple-style scrollytelling for an electric car. The section
3 * is 300vh tall with a sticky 100vh stage. As you scroll, a stylised SVG car
4 * drives through a tunnel of light rings: the rings rush past, the wheels
5 * spin with the scroll velocity, head- and tail-lights bloom, and three specs
6 * (0–100 km/h, range, charge) take the stage one after another with
7 * counting numbers. The first viewport is a complete composed hero.
8 *
9 * Props: model, tagline, specs ({ value, unit, label, decimals? }[]), price,
10 * primaryCta, secondaryCta. CSS vars: --fd-evx-bg, --fd-evx-accent.
11 *
12 * Browser support: all evergreen browsers (position: sticky, SVG).
13 * Motion: one rAF loop reads getBoundingClientRect, eases the scroll progress
14 * (critically damped) and writes transforms/opacity only; it idles when the
15 * section is off-screen or the tab is hidden. Wheel spin and ring rush derive
16 * from the progress velocity. prefers-reduced-motion collapses the scene to
17 * a single static frame with all specs listed.
18 * Accessibility: specs are a <dl> in DOM order; decorative SVG is aria-hidden.
19 */
20import { useEffect, useId, useRef } from "react";
21import type { ReactNode } from "react";
22
23export type EvSpec = { value: number; unit: string; label: string; decimals?: number; prefix?: string };
24
25export interface HeroAutoScrubProps {
26 model?: string;
27 tagline?: ReactNode;
28 specs?: EvSpec[];
29 price?: string;
30 primaryCta?: { label: string; href: string };
31 secondaryCta?: { label: string; href: string };
32}
33
34const DEFAULT_SPECS: EvSpec[] = [
35 { value: 3.4, unit: "s", label: "0–100 km/h with launch control", decimals: 1 },
36 { value: 712, unit: "km", label: "WLTP range on a single charge" },
37 { value: 18, unit: "min", label: "10–80% on a 350 kW charger" },
38];
39
40const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
41const seg = (p: number, a: number, b: number) => clamp((p - a) / (b - a));
42const ease = (t: number) => 1 - Math.pow(1 - t, 3);
43const bump = (p: number, a: number, b: number, c: number, d: number) => Math.min(ease(seg(p, a, b)), 1 - ease(seg(p, c, d)));
44
45const RINGS = 9;
46
47export function HeroAutoScrub({
48 model = "Aurel GT",
49 tagline = (
50 <>
51 Quiet, until it <em>isn’t.</em>
52 </>
53 ),
54 specs = DEFAULT_SPECS,
55 price = "From €89,900 · deliveries spring 2027",
56 primaryCta = { label: "Reserve with €500", href: "#reserve" },
57 secondaryCta = { label: "Book a test drive", href: "#drive" },
58}: HeroAutoScrubProps) {
59 const root = useRef<HTMLElement>(null);
60 const car = useRef<HTMLDivElement>(null);
61 const wheels = useRef<(SVGGElement | null)[]>([]);
62 const rings = useRef<(HTMLDivElement | null)[]>([]);
63 const intro = useRef<HTMLDivElement>(null);
64 const specEls = useRef<(HTMLDivElement | null)[]>([]);
65 const nums = useRef<(HTMLElement | null)[]>([]);
66 const lights = useRef<HTMLDivElement>(null);
67 const road = useRef<HTMLDivElement>(null);
68 const outro = useRef<HTMLDivElement>(null);
69 const bar = useRef<HTMLSpanElement>(null);
70 const hint = useRef<HTMLParagraphElement>(null);
71 const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
72
73 useEffect(() => {
74 const el = root.current;
75 if (!el) return;
76 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
77 el.setAttribute("data-static", "");
78 nums.current.forEach((n, i) => n && (n.textContent = specs[i].value.toFixed(specs[i].decimals ?? 0)));
79 return;
80 }
81 let p = 0;
82 let v = 0;
83 let wheel = 0;
84 let raf = 0;
85 let visible = true;
86 let last = performance.now();
87 const t0 = last;
88 const frame = (now: number) => {
89 raf = 0;
90 const dt = Math.min((now - last) / 1000, 0.05);
91 last = now;
92 const r = el.getBoundingClientRect();
93 const stage = el.firstElementChild as HTMLElement | null;
94 const travel = Math.max(1, r.height - (stage?.offsetHeight ?? window.innerHeight));
95 const target = clamp(-r.top / travel);
96 // critically-damped follow so trackpad steps feel continuous
97 const k = 90;
98 const c = 2 * Math.sqrt(k);
99 const a = k * (target - p) - c * v;
100 v += a * dt;
101 p += v * dt;
102 const t = (now - t0) / 1000;
103 const speed = Math.abs(v);
104 wheel += (0.25 + speed * 40) * dt * 360;
105 wheels.current.forEach((w) => w && w.setAttribute("transform", `rotate(${(wheel % 360).toFixed(1)})`));
106
107 // car: enters from left on load, then drives into centre and grows as it approaches
108 const drive = ease(seg(p, 0.0, 0.9));
109 const bob = Math.sin(t * 9) * (0.4 + speed * 6);
110 if (car.current) {
111 const x = -4 + drive * 4;
112 const sc = 1 + ease(seg(p, 0.7, 1)) * 0.18;
113 car.current.style.transform = `translate3d(${x.toFixed(2)}vw, ${(bob - ease(seg(p, 0.75, 1)) * 40).toFixed(2)}px, 0) scale(${sc.toFixed(4)})`;
114 }
115 // rings rush past; speed blooms
116 for (let i = 0; i < RINGS; i++) {
117 const ring = rings.current[i];
118 if (!ring) continue;
119 const z = ((i / RINGS + p * 2.4 + t * 0.012) % 1);
120 const s = 0.2 + Math.pow(z, 2.2) * 3.2;
121 const o = Math.min(1, z * 3) * (1 - Math.pow(z, 6)) * (0.35 + Math.min(0.65, speed * 8) + seg(p, 0.05, 0.3) * 0.25);
122 ring.style.transform = `translate(-50%, -50%) scale(${s.toFixed(4)})`;
123 ring.style.opacity = o.toFixed(3);
124 }
125 if (lights.current) lights.current.style.opacity = (0.55 + seg(p, 0.05, 0.25) * 0.45 + Math.min(0.3, speed * 3)).toFixed(3);
126 if (road.current) road.current.style.backgroundPositionX = `${(-p * 5200 - t * 20).toFixed(1)}px`;
127 if (intro.current) {
128 const q = seg(p, 0.02, 0.16);
129 intro.current.style.opacity = (1 - q).toFixed(3);
130 intro.current.style.transform = `translate3d(0, ${(-q * 60).toFixed(1)}px, 0) scale(${(1 - q * 0.05).toFixed(4)})`;
131 }
132 const windows: [number, number, number, number][] = [
133 [0.14, 0.22, 0.34, 0.4],
134 [0.38, 0.46, 0.58, 0.64],
135 [0.62, 0.7, 0.82, 0.88],
136 ];
137 specEls.current.forEach((s, i) => {
138 if (!s) return;
139 const w = windows[i] ?? windows[2];
140 const o = bump(p, w[0], w[1], w[2], w[3]);
141 s.style.opacity = o.toFixed(3);
142 s.style.transform = `translate3d(0, ${((1 - o) * (p < w[1] ? 40 : -40)).toFixed(1)}px, 0)`;
143 const n = nums.current[i];
144 if (n) {
145 const cnt = ease(seg(p, w[0], w[1] + 0.04));
146 n.textContent = (specs[i].value * cnt).toFixed(specs[i].decimals ?? 0);
147 }
148 });
149 if (outro.current) {
150 const o = ease(seg(p, 0.86, 0.96));
151 outro.current.style.opacity = o.toFixed(3);
152 outro.current.style.transform = `translate3d(0, ${((1 - o) * 30).toFixed(1)}px, 0)`;
153 outro.current.style.pointerEvents = o > 0.5 ? "auto" : "none";
154 }
155 if (hint.current) hint.current.style.opacity = (1 - seg(p, 0.01, 0.06)).toFixed(3);
156 if (bar.current) bar.current.style.transform = `scaleX(${p.toFixed(4)})`;
157 if (visible && !document.hidden) raf = requestAnimationFrame(frame);
158 };
159 const start = () => {
160 if (!raf && visible && !document.hidden) {
161 last = performance.now();
162 raf = requestAnimationFrame(frame);
163 }
164 };
165 const io = new IntersectionObserver(([e]) => {
166 visible = e.isIntersecting;
167 if (visible) start();
168 });
169 io.observe(el);
170 document.addEventListener("visibilitychange", start);
171 start();
172 return () => {
173 cancelAnimationFrame(raf);
174 io.disconnect();
175 document.removeEventListener("visibilitychange", start);
176 };
177 }, [specs]);
178
179 return (
180 <section ref={root} className="fd-evx" aria-label={model}>
181 <style>{css}</style>
182 <div className="fd-evx-stage">
183 <div className="fd-evx-tunnel" aria-hidden="true">
184 {Array.from({ length: RINGS }, (_, i) => (
185 <div key={i} ref={(n) => { rings.current[i] = n; }} className="fd-evx-ring" />
186 ))}
187 <div className="fd-evx-floorglow" />
188 </div>
189 <div ref={road} className="fd-evx-road" aria-hidden="true" />
190
191 <div ref={intro} className="fd-evx-intro">
192 <p className="fd-evx-model">{model}</p>
193 <h1 className="fd-evx-h1">{tagline}</h1>
194 <p className="fd-evx-price">{price}</p>
195 </div>
196
197 <dl className="fd-evx-specs">
198 {specs.map((s, i) => (
199 <div key={s.label} ref={(n) => { specEls.current[i] = n; }} className="fd-evx-spec">
200 <dd>
201 {s.prefix}
202 <span ref={(n) => { nums.current[i] = n; }}>0</span>
203 <small>{s.unit}</small>
204 </dd>
205 <dt>{s.label}</dt>
206 </div>
207 ))}
208 </dl>
209
210 <div ref={car} className="fd-evx-car" aria-hidden="true">
211 <div ref={lights} className="fd-evx-lights">
212 <i className="fd-evx-beam" />
213 <i className="fd-evx-tail" />
214 </div>
215 <svg viewBox="0 0 900 300" className="fd-evx-svg">
216 <defs>
217 <linearGradient id={`${uid}-body`} x1="0" y1="0" x2="0" y2="1">
218 <stop offset="0" stopColor="#5b6170" />
219 <stop offset="0.28" stopColor="#2a2e38" />
220 <stop offset="0.55" stopColor="#0f1116" />
221 <stop offset="1" stopColor="#050608" />
222 </linearGradient>
223 <linearGradient id={`${uid}-glass`} x1="0" y1="0" x2="1" y2="0">
224 <stop offset="0" stopColor="#0b0d12" />
225 <stop offset="0.45" stopColor="#2b3446" />
226 <stop offset="0.55" stopColor="#5d6b8a" />
227 <stop offset="1" stopColor="#0b0d12" />
228 </linearGradient>
229 <linearGradient id={`${uid}-hl`} x1="0" y1="0" x2="1" y2="0">
230 <stop offset="0" stopColor="#ffffff" stopOpacity="0" />
231 <stop offset="0.5" stopColor="#ffffff" stopOpacity="0.55" />
232 <stop offset="1" stopColor="#ffffff" stopOpacity="0" />
233 </linearGradient>
234 <radialGradient id={`${uid}-rim`}>
235 <stop offset="0.55" stopColor="#1a1d24" />
236 <stop offset="0.8" stopColor="#3a3f4c" />
237 <stop offset="1" stopColor="#0b0c10" />
238 </radialGradient>
239 </defs>
240 <ellipse cx="450" cy="262" rx="400" ry="16" fill="#000" opacity="0.7" />
241 {/* body */}
242 <path
243 d="M70 214 C66 190 78 172 110 164 L250 140 C300 104 360 78 450 72 C540 68 610 84 660 118 L720 150 C790 158 832 170 842 196 L846 214 C846 226 838 232 824 232 L770 232 C766 196 740 172 704 172 C668 172 642 196 638 232 L278 232 C274 196 248 172 212 172 C176 172 150 196 146 232 L92 232 C78 232 70 226 70 214Z"
244 fill={`url(#${uid}-body)`}
245 />
246 {/* greenhouse */}
247 <path d="M270 142 C318 106 372 88 450 84 C528 82 590 96 636 126 L652 140 C600 142 380 146 270 142Z" fill={`url(#${uid}-glass)`} />
248 <path d="M458 86 L462 142" stroke="#08090c" strokeWidth="5" />
249 {/* shoulder highlight */}
250 <path d="M120 170 C300 150 600 150 820 176" stroke={`url(#${uid}-hl)`} strokeWidth="2" fill="none" />
251 <path d="M260 146 C380 150 560 150 660 144" stroke="#9aa6c0" strokeOpacity="0.35" strokeWidth="1.2" fill="none" />
252 {/* door line + handle */}
253 <path d="M470 150 L476 226" stroke="#000" strokeOpacity="0.55" strokeWidth="1.5" />
254 <rect x="500" y="166" width="44" height="4" rx="2" fill="#9aa6c0" opacity="0.5" />
255 {/* light bars */}
256 <path d="M800 170 C820 172 836 178 842 190 L800 186Z" fill="#e8f2ff" className="fd-evx-head" />
257 <path d="M74 192 C76 180 88 172 104 170 L104 184Z" fill="#ff2e3a" className="fd-evx-tl" />
258 {/* wheels */}
259 {[212, 704].map((cx, i) => (
260 <g key={cx} transform={`translate(${cx} 232)`}>
261 <circle r="58" fill="#050506" />
262 <circle r="46" fill={`url(#${uid}-rim)`} />
263 <g ref={(n) => { wheels.current[i] = n; }}>
264 {Array.from({ length: 10 }, (_, k) => (
265 <path key={k} d="M0 -10 L5 -42 L-5 -42Z" fill="#6e7586" transform={`rotate(${k * 36})`} />
266 ))}
267 </g>
268 <circle r="10" fill="#2a2e38" stroke="#7d8597" strokeWidth="1" />
269 </g>
270 ))}
271 </svg>
272 </div>
273
274 <div ref={outro} className="fd-evx-outro">
275 <a className="fd-evx-btn" href={primaryCta.href}>
276 {primaryCta.label}
277 </a>
278 <a className="fd-evx-link" href={secondaryCta.href}>
279 {secondaryCta.label}
280 </a>
281 </div>
282
283 <div className="fd-evx-progress" aria-hidden="true">
284 <span ref={bar} />
285 </div>
286 <p ref={hint} className="fd-evx-hint" aria-hidden="true">
287 Scroll to drive
288 <svg viewBox="0 0 12 18" width="12" height="18"><rect x="1" y="1" width="10" height="16" rx="5" fill="none" stroke="currentColor" /><rect x="5.2" y="4" width="1.6" height="3.4" rx=".8" fill="currentColor" className="fd-evx-wheel" /></svg>
289 </p>
290 </div>
291 </section>
292 );
293}
294
295const css = `
296.fd-evx, .fd-evx *, .fd-evx *::before, .fd-evx *::after { box-sizing: border-box; }
297.fd-evx {
298 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
299 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
300 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
301 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
302 --fd-evx-bg: #000;
303 --fd-evx-accent: #D7E6FF;
304 --fd-evx-sans: "SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
305 position: relative; display: block; margin: 0; padding: 0; height: 300vh; min-height: 2400px;
306 background: var(--fd-evx-bg); color: #F5F5F7; font: 400 1rem/1.5 var(--fd-evx-sans);
307}
308.fd-evx-stage { position: sticky; top: 0; height: 100vh; min-height: 700px; overflow: hidden; isolation: isolate; }
309.fd-evx-tunnel { position: absolute; inset: 0; pointer-events: none; }
310.fd-evx-ring {
311 position: absolute; left: 50%; top: 58%; width: 60vmax; height: 34vmax; border-radius: 48% / 52%;
312 border: 1.5px solid rgba(190, 215, 255, 0.55);
313 box-shadow: 0 0 24px rgba(160, 200, 255, 0.35), inset 0 0 24px rgba(160, 200, 255, 0.2);
314 transform: translate(-50%, -50%) scale(0.5); opacity: 0; will-change: transform, opacity;
315}
316.fd-evx-floorglow { position: absolute; left: 0; right: 0; bottom: 0; height: 46%; background: radial-gradient(60% 70% at 50% 100%, rgba(120, 160, 255, 0.18), transparent 70%); }
317.fd-evx-road {
318 position: absolute; left: 0; right: 0; top: calc(58% + 9vw); height: 2px;
319 background-image: linear-gradient(90deg, rgba(255,255,255,0.45) 0 60px, transparent 60px 140px); background-size: 140px 2px;
320 -webkit-mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent); mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
321 opacity: 0.35;
322}
323.fd-evx-intro { position: absolute; left: 0; right: 0; top: 13vh; text-align: center; padding: 0 24px; will-change: transform, opacity; }
324.fd-evx-model { margin: 0; padding: 0; font: 600 clamp(1.0625rem, 1.6vw, 1.5rem)/1 var(--fd-evx-sans); letter-spacing: -0.01em; color: #86868B; animation: fd-evx-rise 900ms var(--fd-ease-out) 100ms both; }
325.fd-evx-h1 {
326 margin: 14px 0 0; padding: 0; color: #F5F5F7;
327 font: 600 clamp(3rem, 7.4vw, 7rem)/0.96 var(--fd-evx-sans); letter-spacing: -0.05em; text-wrap: balance;
328 animation: fd-evx-blur 1200ms var(--fd-ease-out) 200ms both;
329}
330.fd-evx-h1 em { font-style: normal; background: linear-gradient(90deg, #9ec5ff, #ffffff 50%, #ffb4a8); -webkit-background-clip: text; background-clip: text; color: transparent; }
331.fd-evx-price { margin: 22px 0 0; padding: 0; font: 400 clamp(1rem, 1.2vw, 1.1875rem)/1.4 var(--fd-evx-sans); color: #A1A1A6; letter-spacing: -0.01em; animation: fd-evx-rise 900ms var(--fd-ease-out) 500ms both; }
332
333.fd-evx-car { position: absolute; left: 50%; top: 58%; width: min(980px, 84vw); margin: 0; transform-origin: 50% 80%; will-change: transform; translate: -50% -50%; animation: fd-evx-carin 1600ms var(--fd-ease-out) 300ms both; }
334.fd-evx-svg { display: block; width: 100%; height: auto; overflow: visible; }
335.fd-evx-lights { position: absolute; inset: 0; pointer-events: none; opacity: 0.6; }
336.fd-evx-beam { position: absolute; display: block; left: 92%; top: 49%; width: 70%; height: 30%; background: radial-gradient(50% 50% at 0% 50%, rgba(220,235,255,0.55), transparent 70%); filter: blur(10px); transform-origin: left; }
337.fd-evx-tail { position: absolute; display: block; right: 90%; top: 52%; width: 22%; height: 18%; background: radial-gradient(50% 50% at 100% 50%, rgba(255,40,60,0.6), transparent 70%); filter: blur(8px); }
338.fd-evx-head { filter: drop-shadow(0 0 6px #cfe2ff); }
339.fd-evx-tl { filter: drop-shadow(0 0 6px #ff2e3a); }
340
341.fd-evx-specs { position: absolute; left: 0; right: 0; top: 12vh; margin: 0; padding: 0 24px; text-align: center; }
342.fd-evx-spec { position: absolute; left: 0; right: 0; margin: 0; opacity: 0; will-change: transform, opacity; }
343.fd-evx-spec dd { display: flex; justify-content: center; align-items: baseline; gap: 10px; margin: 0; padding: 0; font: 600 clamp(4rem, 11vw, 10rem)/0.9 var(--fd-evx-sans); letter-spacing: -0.06em; color: #F5F5F7; font-variant-numeric: tabular-nums; }
344.fd-evx-spec dd small { font: 600 clamp(1.5rem, 3vw, 2.75rem)/1 var(--fd-evx-sans); letter-spacing: -0.03em; color: #86868B; }
345.fd-evx-spec dt { margin: 14px 0 0; padding: 0; font: 500 clamp(1rem, 1.3vw, 1.25rem)/1.3 var(--fd-evx-sans); color: #A1A1A6; letter-spacing: -0.01em; }
346
347.fd-evx-outro { position: absolute; left: 0; right: 0; bottom: 12vh; display: flex; justify-content: center; align-items: center; gap: 28px; opacity: 0; pointer-events: none; }
348.fd-evx-btn { display: inline-flex; align-items: center; margin: 0; height: 50px; padding: 0 26px; border-radius: 9999px; background: #F5F5F7; color: #000; text-decoration: none; font: 500 1.0625rem/1 var(--fd-evx-sans); letter-spacing: -0.01em; transition: transform var(--fd-dur-2) var(--fd-spring); }
349.fd-evx-btn:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
350.fd-evx-link { margin: 0; padding: 0; color: #2997FF; text-decoration: none; font: 400 1.0625rem/1 var(--fd-evx-sans); }
351.fd-evx-link::after { content: " ›"; }
352.fd-evx-link:hover { text-decoration: underline; }
353.fd-evx-btn:focus-visible, .fd-evx-link:focus-visible { outline: 2px solid rgba(41,151,255,0.7); outline-offset: 3px; }
354.fd-evx-progress { position: absolute; left: 50%; bottom: 36px; width: 120px; height: 2px; margin-left: -60px; border-radius: 9999px; background: rgba(255,255,255,0.14); overflow: hidden; }
355.fd-evx-progress span { position: absolute; inset: 0; background: #F5F5F7; transform-origin: left; transform: scaleX(0); }
356.fd-evx-hint { position: absolute; will-change: opacity; left: 0; right: 0; bottom: 52px; display: flex; justify-content: center; align-items: center; gap: 10px; margin: 0; padding: 0; color: #86868B; font: 500 0.8125rem/1 var(--fd-evx-sans); animation: fd-evx-rise 900ms var(--fd-ease-out) 1400ms both; }
357.fd-evx-hint svg { display: block; }
358.fd-evx-wheel { animation: fd-evx-scrollwheel 1800ms var(--fd-ease-in-out) infinite; }
359
360@keyframes fd-evx-rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
361@keyframes fd-evx-blur { from { opacity: 0; filter: blur(16px); transform: scale(0.97); } to { opacity: 1; filter: none; transform: none; } }
362@keyframes fd-evx-carin { from { opacity: 0; translate: -70% -50%; filter: blur(8px) brightness(0.3); } to { opacity: 1; translate: -50% -50%; filter: none; } }
363@keyframes fd-evx-scrollwheel { 0% { transform: translateY(0); opacity: 1; } 70% { transform: translateY(5px); opacity: 0; } 100% { transform: translateY(0); opacity: 0; } }
364
365@media (max-width: 760px) {
366 .fd-evx-car { width: 104vw; top: 60%; }
367 .fd-evx-intro { top: 15vh; }
368 .fd-evx-specs { top: 16vh; }
369 .fd-evx-ring { width: 120vw; height: 70vw; top: 60%; }
370 .fd-evx-outro { flex-direction: column; gap: 18px; bottom: 14vh; }
371 .fd-evx-hint { bottom: 50px; }
372}
373
374.fd-evx[data-static] { height: auto; min-height: 0; }
375.fd-evx[data-static] .fd-evx-stage { position: relative; height: auto; min-height: 100vh; padding: 0 0 80px; }
376.fd-evx[data-static] .fd-evx-intro { position: relative; top: auto; padding-top: 14vh; }
377.fd-evx[data-static] .fd-evx-car { position: relative; left: auto; top: auto; translate: none; margin: 40px auto 0; }
378.fd-evx[data-static] .fd-evx-specs { position: relative; top: auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 32px; max-width: 1000px; margin: 40px auto 0; }
379.fd-evx[data-static] .fd-evx-spec { position: relative; opacity: 1; }
380.fd-evx[data-static] .fd-evx-spec dd { font-size: 3.5rem; }
381.fd-evx[data-static] .fd-evx-outro { position: relative; bottom: auto; opacity: 1; pointer-events: auto; margin-top: 48px; }
382.fd-evx[data-static] .fd-evx-ring, .fd-evx[data-static] .fd-evx-progress, .fd-evx[data-static] .fd-evx-hint { display: none; }
383@media (prefers-reduced-motion: reduce) { .fd-evx *, .fd-evx *::before { animation: none !important; } }
384`;
385
386/* ───────────────────────── Demo ───────────────────────── */
387
388export function Demo() {
389 return (
390 <div className="fd-evx-demo">
391 <style>{demoCss}</style>
392 <header className="fd-evx-demo-nav">
393 <a className="fd-evx-demo-logo" href="#top">AUREL</a>
394 <nav aria-label="Models">
395 <a href="#gt" aria-current="page">GT</a>
396 <a href="#tourer">Tourer</a>
397 <a href="#x">X</a>
398 <a href="#charging">Charging</a>
399 </nav>
400 <a className="fd-evx-demo-cta" href="#configure">Configure</a>
401 </header>
402 <HeroAutoScrub />
403 <section className="fd-evx-demo-after" aria-label="Next">
404 <p>Designed in Gothenburg. Built in Graz. Charged anywhere.</p>
405 </section>
406 </div>
407 );
408}
409
410const demoCss = `
411.fd-evx-demo { position: relative; margin: 0; padding: 0; min-height: 100vh; background: #000; }
412.fd-evx-demo *, .fd-evx-demo *::before, .fd-evx-demo *::after { box-sizing: border-box; }
413.fd-evx-demo-nav { position: fixed; z-index: 20; top: 0; left: 0; right: 0; height: 52px; margin: 0; padding: 0 max(24px, calc(50% - 560px)); display: flex; align-items: center; justify-content: space-between; gap: 20px; background: rgba(0,0,0,0.72); -webkit-backdrop-filter: saturate(1.8) blur(20px); backdrop-filter: saturate(1.8) blur(20px); border-bottom: 1px solid rgba(255,255,255,0.08); }
414.fd-evx-demo-logo { margin: 0; color: #F5F5F7; text-decoration: none; font: 600 0.9375rem/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", sans-serif; letter-spacing: 0.42em; }
415.fd-evx-demo-nav nav { display: flex; gap: 28px; }
416.fd-evx-demo-nav nav a { color: #A1A1A6; text-decoration: none; font: 400 0.8125rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; transition: color 120ms; }
417.fd-evx-demo-nav nav a[aria-current] { color: #F5F5F7; }
418.fd-evx-demo-cta { display: inline-flex; align-items: center; height: 28px; padding: 0 14px; border-radius: 9999px; background: #0071E3; color: #fff; text-decoration: none; font: 400 0.8125rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; }
419.fd-evx-demo-nav a:focus-visible { outline: 2px solid rgba(41,151,255,.7); outline-offset: 2px; border-radius: 6px; }
420.fd-evx-demo-after { margin: 0; padding: 120px 24px 160px; text-align: center; background: #000; }
421.fd-evx-demo-after p { margin: 0; padding: 0; color: #86868B; font: 600 clamp(1.5rem, 3vw, 2.5rem)/1.15 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", sans-serif; letter-spacing: -0.03em; text-wrap: balance; }
422@media (max-width: 760px) { .fd-evx-demo-nav nav { display: none; } }
423`;

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.