Grid Globe Hero

A dotted canvas globe with energy arcs pulsing between sites, and live MW and tCO₂ counters ticking beneath.

  • Heroes
  • Climate & energy
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (2D canvas, DPR capped at 2).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-climate-data.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-climate-data.tsx
1/**
2 * HeroClimateData — a dark climate/energy hero. A canvas globe made of dotted
3 * land (procedural continents, no map data) spins slowly; glowing arcs carry
4 * energy between grid nodes, and live counters (MW dispatched, tCO₂ avoided,
5 * sites online) tick in real time. Drag or move the pointer to tilt the
6 * globe; on scroll the globe rises and scales while the copy parallaxes away.
7 *
8 * Props: title, lede, primaryCta, secondaryCta, metrics
9 * ({ label, base, perSecond, unit, decimals }[]), nodes ([lat, lon][]).
10 * CSS vars: --fd-grd-bg, --fd-grd-accent, --fd-grd-accent-2.
11 *
12 * Browser support: all evergreen browsers (2D canvas). DPR capped at 2.
13 * Motion: single rAF loop, paused off-screen (IntersectionObserver) and in
14 * hidden tabs; pointer tilt on a spring (k=380, c=30, mass 4); scroll via
15 * getBoundingClientRect (transform/opacity). prefers-reduced-motion renders
16 * one static frame and frozen counters.
17 * Accessibility: canvas is aria-hidden; metrics are a <dl> whose values are
18 * updated at most every 500 ms and are not announced (aria-live off).
19 */
20import { useEffect, useRef } from "react";
21import type { ReactNode } from "react";
22
23export type GridMetric = { label: string; base: number; perSecond: number; unit: string; decimals?: number };
24
25export interface HeroClimateDataProps {
26 eyebrow?: ReactNode;
27 title?: ReactNode;
28 lede?: ReactNode;
29 primaryCta?: { label: string; href: string };
30 secondaryCta?: { label: string; href: string };
31 metrics?: GridMetric[];
32 nodes?: [number, number][];
33}
34
35const DEFAULT_METRICS: GridMetric[] = [
36 { label: "Clean power dispatched now", base: 18432.6, perSecond: 3.7, unit: "MW", decimals: 1 },
37 { label: "CO₂ avoided this year", base: 2841907, perSecond: 41, unit: "tCO₂" },
38 { label: "Batteries & farms online", base: 3214, perSecond: 0.02, unit: "sites" },
39];
40
41// [lat, lon] — wind farms, solar parks and storage sites (fictional)
42const DEFAULT_NODES: [number, number][] = [
43 [55.7, 8.1], [52.4, 4.9], [40.4, -3.7], [37.4, -122], [34, -117.5], [31.5, -100], [41.9, -87.6], [-23.5, -46.6],
44 [-33.9, 151.2], [-31.9, 115.9], [35.7, 139.7], [1.35, 103.8], [25.2, 55.3], [19, 72.8], [-26.2, 28], [64.1, -21.9], [59.3, 18], [45.5, -73.6],
45];
46
47const ARCS: [number, number][] = [
48 [0, 1], [1, 2], [2, 15], [3, 4], [4, 5], [5, 6], [6, 17], [17, 15], [7, 14], [8, 9], [10, 11], [11, 13], [12, 13], [12, 2], [16, 0], [9, 11], [14, 12], [3, 10],
49];
50
51// tiny deterministic value-noise for continents
52function hash(x: number, y: number) {
53 const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
54 return s - Math.floor(s);
55}
56function vnoise(x: number, y: number) {
57 const xi = Math.floor(x);
58 const yi = Math.floor(y);
59 const xf = x - xi;
60 const yf = y - yi;
61 const u = xf * xf * (3 - 2 * xf);
62 const v = yf * yf * (3 - 2 * yf);
63 const a = hash(xi, yi);
64 const b = hash(xi + 1, yi);
65 const c = hash(xi, yi + 1);
66 const d = hash(xi + 1, yi + 1);
67 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
68}
69
70// rough continental blobs (lat, lon, radius°) so the land reads as Earth-like
71const BLOBS: [number, number, number][] = [
72 [50, 10, 22], [60, 60, 30], [45, 95, 28], [30, 110, 18], [22, 78, 12], [25, 45, 12], [8, 20, 22], [-12, 25, 20], [-28, 25, 10],
73 [50, -100, 24], [60, -110, 18], [35, -95, 16], [68, -45, 10], [15, -90, 6], [-8, -60, 18], [-25, -60, 13], [-42, -68, 6],
74 [-25, 134, 16], [0, 115, 6], [-4, 140, 5], [36, 138, 4], [64, 100, 16], [8, 0, 10],
75];
76function isLand(lat: number, lon: number) {
77 let v = 0;
78 for (const [la, lo, r] of BLOBS) {
79 let dl = lon - lo;
80 if (dl > 180) dl -= 360;
81 if (dl < -180) dl += 360;
82 const d = Math.hypot((lat - la) * 1.1, dl * Math.cos((lat * Math.PI) / 180));
83 v = Math.max(v, 1 - d / r);
84 }
85 v += (vnoise(lon / 9 + 20, lat / 9 + 20) - 0.5) * 0.7;
86 return v > 0.12;
87}
88
89export function HeroClimateData({
90 eyebrow = "Gridline · virtual power plant",
91 title = (
92 <>
93 Every battery, turbine and panel —
94 <span className="fd-grd-glow"> one grid that thinks.</span>
95 </>
96 ),
97 lede = "Gridline orchestrates 3,214 distributed energy sites across 19 markets, dispatching clean power in under 200 ms when the grid needs it most.",
98 primaryCta = { label: "Connect your assets", href: "#connect" },
99 secondaryCta = { label: "Live grid map", href: "#map" },
100 metrics = DEFAULT_METRICS,
101 nodes = DEFAULT_NODES,
102}: HeroClimateDataProps) {
103 const root = useRef<HTMLElement>(null);
104 const cvs = useRef<HTMLCanvasElement>(null);
105 const globeWrap = useRef<HTMLDivElement>(null);
106 const copy = useRef<HTMLDivElement>(null);
107 const vals = useRef<(HTMLElement | null)[]>([]);
108
109 useEffect(() => {
110 const el = root.current;
111 const canvas = cvs.current;
112 if (!el || !canvas) return;
113 const ctx = canvas.getContext("2d");
114 if (!ctx) return;
115 const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
116
117 // sample land points on a fibonacci sphere
118 const N = 5200;
119 const pts: { x: number; y: number; z: number; land: boolean }[] = [];
120 const golden = Math.PI * (3 - Math.sqrt(5));
121 for (let i = 0; i < N; i++) {
122 const y = 1 - (i / (N - 1)) * 2;
123 const r = Math.sqrt(1 - y * y);
124 const th = golden * i;
125 const x = Math.cos(th) * r;
126 const z = Math.sin(th) * r;
127 const lat = (Math.asin(y) * 180) / Math.PI;
128 const lon = (Math.atan2(z, x) * 180) / Math.PI;
129 const land = isLand(lat, lon);
130 if (land || i % 5 === 0) pts.push({ x, y, z, land });
131 }
132 const toVec = (lat: number, lon: number) => {
133 const la = (lat * Math.PI) / 180;
134 const lo = (lon * Math.PI) / 180;
135 return { x: Math.cos(la) * Math.cos(lo), y: Math.sin(la), z: Math.cos(la) * Math.sin(lo) };
136 };
137 const nv = nodes.map(([la, lo]) => toVec(la, lo));
138 // great-circle arcs, lifted
139 const arcs = ARCS.filter(([a, b]) => a < nv.length && b < nv.length).map(([a, b], k) => {
140 const A = nv[a];
141 const B = nv[b];
142 const dot = Math.max(-1, Math.min(1, A.x * B.x + A.y * B.y + A.z * B.z));
143 const om = Math.acos(dot);
144 const seg: { x: number; y: number; z: number }[] = [];
145 const S = 48;
146 for (let i = 0; i <= S; i++) {
147 const t = i / S;
148 const s1 = Math.sin((1 - t) * om) / Math.sin(om || 1);
149 const s2 = Math.sin(t * om) / Math.sin(om || 1);
150 const lift = 1 + Math.sin(t * Math.PI) * (0.08 + om * 0.12);
151 seg.push({ x: (A.x * s1 + B.x * s2) * lift, y: (A.y * s1 + B.y * s2) * lift, z: (A.z * s1 + B.z * s2) * lift });
152 }
153 return { seg, phase: (k * 0.37) % 1, speed: 0.18 + ((k * 13) % 7) * 0.025 };
154 });
155
156 let W = 0;
157 let H = 0;
158 let dpr = 1;
159 const resize = () => {
160 const r = canvas.getBoundingClientRect();
161 dpr = Math.min(2, window.devicePixelRatio || 1);
162 W = r.width;
163 H = r.height;
164 canvas.width = Math.round(W * dpr);
165 canvas.height = Math.round(H * dpr);
166 };
167 resize();
168 const ro = new ResizeObserver(resize);
169 ro.observe(canvas);
170
171 const sp = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0 };
172 const onMove = (e: PointerEvent) => {
173 if (e.pointerType === "touch") return;
174 const r = el.getBoundingClientRect();
175 sp.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
176 sp.ty = ((e.clientY - r.top) / r.height) * 2 - 1;
177 };
178 el.addEventListener("pointermove", onMove);
179
180 const t0 = performance.now();
181 let last = t0;
182 let lastText = 0;
183 const draw = (now: number) => {
184 const dt = Math.min((now - last) / 1000, 0.05);
185 last = now;
186 for (let i = 0; i < 2; i++) {
187 const h = dt / 2;
188 sp.vx += ((-380 * (sp.x - sp.tx) - 30 * sp.vx) / 4) * h;
189 sp.vy += ((-380 * (sp.y - sp.ty) - 30 * sp.vy) / 4) * h;
190 sp.x += sp.vx * h;
191 sp.y += sp.vy * h;
192 }
193 const t = Math.max(0, (now - t0) / 1000);
194 const rotY = 2.2 + t * 0.07 + sp.x * 0.35;
195 const rotX = -0.32 + sp.y * 0.18;
196 const cy = Math.cos(rotY);
197 const sy = Math.sin(rotY);
198 const cx = Math.cos(rotX);
199 const sx = Math.sin(rotX);
200 const R = Math.min(W, H) * 0.44;
201 const ox = W / 2;
202 const oy = H / 2;
203 const proj = (p: { x: number; y: number; z: number }) => {
204 const x1 = p.x * cy - p.z * sy;
205 const z1 = p.x * sy + p.z * cy;
206 const y2 = p.y * cx - z1 * sx;
207 const z2 = p.y * sx + z1 * cx;
208 return { X: ox + x1 * R, Y: oy - y2 * R, Z: z2 };
209 };
210 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
211 ctx.clearRect(0, 0, W, H);
212 // atmosphere
213 const g = ctx.createRadialGradient(ox, oy, R * 0.9, ox, oy, R * 1.28);
214 g.addColorStop(0, "rgba(64, 224, 168, 0.20)");
215 g.addColorStop(0.4, "rgba(64, 224, 168, 0.06)");
216 g.addColorStop(1, "rgba(64, 224, 168, 0)");
217 ctx.fillStyle = g;
218 ctx.beginPath();
219 ctx.arc(ox, oy, R * 1.3, 0, Math.PI * 2);
220 ctx.fill();
221 // sphere body
222 const body = ctx.createRadialGradient(ox - R * 0.35, oy - R * 0.4, R * 0.1, ox, oy, R);
223 body.addColorStop(0, "#0f2a26");
224 body.addColorStop(1, "#050b0c");
225 ctx.fillStyle = body;
226 ctx.beginPath();
227 ctx.arc(ox, oy, R, 0, Math.PI * 2);
228 ctx.fill();
229 // dots
230 for (let i = 0; i < pts.length; i++) {
231 const p = pts[i];
232 const q = proj(p);
233 if (q.Z < -0.05) continue;
234 const shade = 0.25 + q.Z * 0.75;
235 if (p.land) {
236 ctx.fillStyle = `rgba(150, 255, 214, ${(0.25 + shade * 0.6).toFixed(3)})`;
237 const s = 1.15 + q.Z * 0.6;
238 ctx.fillRect(q.X - s / 2, q.Y - s / 2, s, s);
239 } else {
240 ctx.fillStyle = `rgba(120, 170, 190, ${(shade * 0.14).toFixed(3)})`;
241 ctx.fillRect(q.X - 0.5, q.Y - 0.5, 1, 1);
242 }
243 }
244 // arcs
245 ctx.lineCap = "round";
246 for (const a of arcs) {
247 const P = a.seg.map(proj);
248 ctx.beginPath();
249 let started = false;
250 for (const q of P) {
251 if (q.Z < -0.02) {
252 started = false;
253 continue;
254 }
255 if (!started) ctx.moveTo(q.X, q.Y);
256 else ctx.lineTo(q.X, q.Y);
257 started = true;
258 }
259 ctx.strokeStyle = "rgba(94, 234, 180, 0.16)";
260 ctx.lineWidth = 1;
261 ctx.stroke();
262 // pulse
263 const head = (((a.phase + t * a.speed) % 1) + 1) % 1;
264 const len = 0.22;
265 ctx.beginPath();
266 started = false;
267 for (let i = 0; i < P.length; i++) {
268 const u = i / (P.length - 1);
269 if (u < head - len || u > head) continue;
270 const q = P[i];
271 if (q.Z < -0.02) {
272 started = false;
273 continue;
274 }
275 if (!started) ctx.moveTo(q.X, q.Y);
276 else ctx.lineTo(q.X, q.Y);
277 started = true;
278 }
279 const hq = P[Math.min(P.length - 1, Math.floor(head * (P.length - 1)))];
280 const tq = P[Math.max(0, Math.floor((head - len) * (P.length - 1)))];
281 const lg = ctx.createLinearGradient(tq.X, tq.Y, hq.X, hq.Y);
282 lg.addColorStop(0, "rgba(255, 214, 102, 0)");
283 lg.addColorStop(1, "rgba(255, 228, 140, 0.95)");
284 ctx.strokeStyle = lg;
285 ctx.lineWidth = 1.8;
286 ctx.stroke();
287 if (hq.Z > 0) {
288 ctx.fillStyle = "rgba(255, 236, 170, 0.95)";
289 ctx.beginPath();
290 ctx.arc(hq.X, hq.Y, 2, 0, Math.PI * 2);
291 ctx.fill();
292 }
293 }
294 // nodes
295 for (let i = 0; i < nv.length; i++) {
296 const q = proj(nv[i]);
297 if (q.Z < 0) continue;
298 const pulse = (t * 0.6 + i * 0.13) % 1;
299 ctx.strokeStyle = `rgba(94, 234, 180, ${(0.6 * (1 - pulse) * q.Z).toFixed(3)})`;
300 ctx.lineWidth = 1;
301 ctx.beginPath();
302 ctx.arc(q.X, q.Y, 3 + pulse * 12, 0, Math.PI * 2);
303 ctx.stroke();
304 ctx.fillStyle = "#5EEAB4";
305 ctx.beginPath();
306 ctx.arc(q.X, q.Y, 2.6, 0, Math.PI * 2);
307 ctx.fill();
308 }
309 // rim light
310 const rim = ctx.createRadialGradient(ox, oy, R * 0.82, ox, oy, R);
311 rim.addColorStop(0, "rgba(94,234,180,0)");
312 rim.addColorStop(1, "rgba(94,234,180,0.18)");
313 ctx.fillStyle = rim;
314 ctx.beginPath();
315 ctx.arc(ox, oy, R, 0, Math.PI * 2);
316 ctx.fill();
317
318 // scroll + counters
319 const r = el.getBoundingClientRect();
320 const p = Math.min(1, Math.max(0, -r.top / Math.max(1, r.height)));
321 if (globeWrap.current) globeWrap.current.style.transform = `translate3d(0, ${(-p * 120).toFixed(1)}px, 0) scale(${(1 + p * 0.35).toFixed(4)})`;
322 if (copy.current) {
323 copy.current.style.transform = `translate3d(0, ${(p * -60).toFixed(1)}px, 0)`;
324 copy.current.style.opacity = `${(1 - p * 1.5).toFixed(3)}`;
325 }
326 if (now - lastText > 120) {
327 lastText = now;
328 const secs = (Date.now() / 1000) % 86400;
329 metrics.forEach((m, i) => {
330 const n = vals.current[i];
331 if (!n) return;
332 const wobble = m.unit === "MW" ? Math.sin(t * 0.9) * 22 + Math.sin(t * 2.3) * 6 : 0;
333 const v = m.base + m.perSecond * (m.unit === "MW" ? t : secs) + wobble;
334 n.textContent = v.toLocaleString("en-US", { minimumFractionDigits: m.decimals ?? 0, maximumFractionDigits: m.decimals ?? 0 });
335 });
336 }
337 };
338
339 if (reduced) {
340 draw(t0 + 1);
341 return () => {
342 ro.disconnect();
343 el.removeEventListener("pointermove", onMove);
344 };
345 }
346 let raf = 0;
347 let visible = true;
348 const loop = (now: number) => {
349 raf = 0;
350 draw(now);
351 if (visible && !document.hidden) raf = requestAnimationFrame(loop);
352 };
353 const start = () => {
354 if (!raf && visible && !document.hidden) {
355 last = performance.now();
356 raf = requestAnimationFrame(loop);
357 }
358 };
359 const io = new IntersectionObserver(([e]) => {
360 visible = e.isIntersecting;
361 if (visible) start();
362 });
363 io.observe(el);
364 document.addEventListener("visibilitychange", start);
365 start();
366 return () => {
367 cancelAnimationFrame(raf);
368 io.disconnect();
369 ro.disconnect();
370 document.removeEventListener("visibilitychange", start);
371 el.removeEventListener("pointermove", onMove);
372 };
373 }, [metrics, nodes]);
374
375 return (
376 <section ref={root} className="fd-grd" aria-label="Gridline">
377 <style>{css}</style>
378 <div className="fd-grd-stars" aria-hidden="true" />
379 <div ref={globeWrap} className="fd-grd-globe">
380 <canvas ref={cvs} className="fd-grd-canvas" aria-hidden="true" />
381 </div>
382 <div ref={copy} className="fd-grd-copy">
383 <p className="fd-grd-eyebrow">
384 <span className="fd-grd-live" aria-hidden="true" />
385 {eyebrow}
386 </p>
387 <h1 className="fd-grd-h1">{title}</h1>
388 <p className="fd-grd-lede">{lede}</p>
389 <div className="fd-grd-ctas">
390 <a className="fd-grd-btn" href={primaryCta.href}>
391 {primaryCta.label}
392 </a>
393 <a className="fd-grd-ghost" href={secondaryCta.href}>
394 <span className="fd-grd-live" aria-hidden="true" />
395 {secondaryCta.label}
396 </a>
397 </div>
398 </div>
399 <dl className="fd-grd-metrics">
400 {metrics.map((m, i) => (
401 <div key={m.label} className="fd-grd-m" style={{ animationDelay: `${700 + i * 120}ms` }}>
402 <dt>{m.label}</dt>
403 <dd>
404 <span ref={(n) => { vals.current[i] = n; }}>
405 {m.base.toLocaleString("en-US", { minimumFractionDigits: m.decimals ?? 0, maximumFractionDigits: m.decimals ?? 0 })}
406 </span>
407 <small>{m.unit}</small>
408 </dd>
409 <svg className="fd-grd-spark" viewBox="0 0 120 24" preserveAspectRatio="none" aria-hidden="true">
410 <path d={SPARKS[i % SPARKS.length]} fill="none" stroke="currentColor" strokeWidth="1.4" vectorEffect="non-scaling-stroke" pathLength="1" />
411 </svg>
412 </div>
413 ))}
414 </dl>
415 </section>
416 );
417}
418
419const SPARKS = [
420 "M0 18 L10 16 L20 17 L30 12 L40 14 L50 9 L60 11 L70 7 L80 9 L90 5 L100 7 L110 3 L120 4",
421 "M0 22 L15 20 L30 18 L45 16 L60 13 L75 11 L90 8 L105 6 L120 3",
422 "M0 14 L12 14 L24 12 L36 13 L48 10 L60 11 L72 9 L84 9 L96 7 L108 7 L120 5",
423];
424
425const css = `
426.fd-grd, .fd-grd *, .fd-grd *::before, .fd-grd *::after { box-sizing: border-box; }
427.fd-grd {
428 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
429 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
430 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
431 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
432 --fd-grd-bg: #030807;
433 --fd-grd-accent: #5EEAB4;
434 --fd-grd-accent-2: #FFE48C;
435 --fd-grd-sans: "SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
436 --fd-grd-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
437 position: relative; display: block; overflow: hidden; margin: 0; padding: 0; min-height: 880px;
438 background: radial-gradient(80% 60% at 70% 50%, #08201b 0%, var(--fd-grd-bg) 65%); color: #E8F5F0;
439 font: 400 1rem/1.5 var(--fd-grd-sans); isolation: isolate;
440}
441.fd-grd-stars {
442 position: absolute; inset: 0; pointer-events: none; opacity: 0.6;
443 background-image: radial-gradient(1px 1px at 12% 18%, #fff 50%, transparent), radial-gradient(1px 1px at 32% 72%, #cfe 50%, transparent), radial-gradient(1px 1px at 58% 12%, #fff 50%, transparent), radial-gradient(1px 1px at 84% 30%, #dfe 50%, transparent), radial-gradient(1px 1px at 92% 82%, #fff 50%, transparent), radial-gradient(1px 1px at 6% 88%, #fff 50%, transparent), radial-gradient(1.5px 1.5px at 46% 46%, #fff 50%, transparent);
444}
445.fd-grd-globe { position: absolute; right: -6%; top: 50%; width: min(980px, 72vw); aspect-ratio: 1; margin-top: max(-490px, -36vw); will-change: transform; animation: fd-grd-globein 2000ms var(--fd-ease-out) both; }
446.fd-grd-canvas { display: block; width: 100%; height: 100%; }
447.fd-grd-copy { position: relative; z-index: 2; max-width: 1240px; margin: 0 auto; padding: 150px 48px 0; will-change: transform, opacity; }
448.fd-grd-eyebrow {
449 display: inline-flex; align-items: center; gap: 10px; margin: 0 0 24px; padding: 0;
450 font: 500 0.75rem/1 var(--fd-grd-mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--fd-grd-accent);
451 animation: fd-grd-rise var(--fd-dur-4) var(--fd-ease-out) 100ms both;
452}
453.fd-grd-live { position: relative; display: block; width: 7px; height: 7px; border-radius: 9999px; background: var(--fd-grd-accent); box-shadow: 0 0 12px var(--fd-grd-accent); animation: fd-grd-blink 2s var(--fd-ease-in-out) infinite; }
454.fd-grd-h1 {
455 margin: 0; padding: 0; max-width: 13ch; color: #F2FBF7;
456 font: 600 clamp(2.5rem, 5.4vw, 4.875rem)/1 var(--fd-grd-sans); letter-spacing: -0.045em; text-wrap: balance;
457 animation: fd-grd-blur 1100ms var(--fd-ease-out) 180ms both;
458}
459.fd-grd-glow { background: linear-gradient(90deg, #5EEAB4, #C8FFE6 60%, #FFE48C); -webkit-background-clip: text; background-clip: text; color: transparent; }
460.fd-grd-lede { margin: 24px 0 0; padding: 0; max-width: 29rem; color: rgba(214, 236, 228, 0.7); font: 400 clamp(1rem, 1.2vw, 1.125rem)/1.55 var(--fd-grd-sans); letter-spacing: -0.01em; text-wrap: pretty; animation: fd-grd-rise var(--fd-dur-4) var(--fd-ease-out) 380ms both; }
461.fd-grd-ctas { display: flex; flex-wrap: wrap; gap: 12px; margin: 34px 0 0; animation: fd-grd-rise var(--fd-dur-4) var(--fd-ease-out) 480ms both; }
462.fd-grd-btn {
463 display: inline-flex; align-items: center; margin: 0; height: 48px; padding: 0 22px; border-radius: 9999px; border: 0;
464 background: var(--fd-grd-accent); color: #02130D; text-decoration: none; font: 600 0.9375rem/1 var(--fd-grd-sans); letter-spacing: -0.01em;
465 box-shadow: 0 0 0 1px rgba(94,234,180,0.4), 0 10px 40px -8px rgba(94,234,180,0.55);
466 transition: transform var(--fd-dur-2) var(--fd-spring), box-shadow var(--fd-dur-2);
467}
468.fd-grd-btn:hover { box-shadow: 0 0 0 1px rgba(94,234,180,0.6), 0 14px 50px -6px rgba(94,234,180,0.7); }
469.fd-grd-ghost { display: inline-flex; align-items: center; gap: 10px; margin: 0; height: 48px; padding: 0 20px; border-radius: 9999px; border: 1px solid rgba(94,234,180,0.25); background: rgba(94,234,180,0.05); color: #E8F5F0; text-decoration: none; font: 500 0.9375rem/1 var(--fd-grd-sans); transition: transform var(--fd-dur-2) var(--fd-spring), border-color var(--fd-dur-1); }
470.fd-grd-ghost:hover { border-color: rgba(94,234,180,0.5); }
471.fd-grd-btn:active, .fd-grd-ghost:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
472.fd-grd-btn:focus-visible, .fd-grd-ghost:focus-visible { outline: 2px solid rgba(94,234,180,0.7); outline-offset: 2px; }
473.fd-grd-metrics {
474 position: absolute; z-index: 2; left: 50%; bottom: 40px; transform: translateX(-50%); width: min(1144px, calc(100% - 96px)); margin: 0; padding: 0;
475 display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0;
476 border-top: 1px solid rgba(94,234,180,0.16);
477}
478.fd-grd-m { position: relative; margin: 0; padding: 22px 24px 0 0; animation: fd-grd-rise var(--fd-dur-4) var(--fd-ease-out) both; }
479.fd-grd-m + .fd-grd-m { padding-left: 24px; border-left: 1px solid rgba(94,234,180,0.1); }
480.fd-grd-m dt { margin: 0; padding: 0; font: 500 0.6875rem/1.3 var(--fd-grd-mono); letter-spacing: 0.08em; text-transform: uppercase; color: rgba(214,236,228,0.55); }
481.fd-grd-m dd { display: flex; align-items: baseline; gap: 8px; margin: 10px 0 0; padding: 0; font: 500 clamp(1.5rem, 2.6vw, 2.25rem)/1 var(--fd-grd-mono); letter-spacing: -0.04em; color: #F2FBF7; font-variant-numeric: tabular-nums; }
482.fd-grd-m dd small { font: 500 0.8125rem/1 var(--fd-grd-mono); letter-spacing: 0; color: var(--fd-grd-accent); }
483.fd-grd-spark { position: absolute; right: 24px; top: 26px; width: 96px; height: 22px; display: block; color: var(--fd-grd-accent); opacity: 0.7; }
484.fd-grd-spark path { stroke-dasharray: 1; stroke-dashoffset: 1; animation: fd-grd-draw 1600ms var(--fd-ease-out) 1000ms forwards; }
485
486@keyframes fd-grd-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
487@keyframes fd-grd-blur { from { opacity: 0; filter: blur(12px); transform: translateY(12px); } to { opacity: 1; filter: none; transform: none; } }
488@keyframes fd-grd-globein { from { opacity: 0; transform: scale(0.82) rotate(-8deg); filter: blur(12px); } to { opacity: 1; transform: none; filter: none; } }
489@keyframes fd-grd-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
490@keyframes fd-grd-draw { to { stroke-dashoffset: 0; } }
491
492@media (max-width: 900px) {
493 .fd-grd { min-height: 844px; }
494 .fd-grd-globe { right: auto; left: 50%; top: auto; bottom: -34vw; width: 120vw; margin: 0 0 0 -60vw; opacity: 0.9; }
495 .fd-grd-copy { text-shadow: 0 1px 12px #030807; }
496 .fd-grd-copy { padding: 104px 20px 0; }
497 .fd-grd-metrics { width: calc(100% - 40px); bottom: 24px; grid-template-columns: 1fr 1fr; }
498 .fd-grd-m { padding: 16px 12px 0 0; }
499 .fd-grd-m + .fd-grd-m { padding-left: 12px; }
500 .fd-grd-m:nth-child(3) { display: none; }
501 .fd-grd-spark { display: none; }
502}
503@media (prefers-reduced-motion: reduce) { .fd-grd *, .fd-grd *::before { animation: none !important; } .fd-grd-spark path { stroke-dashoffset: 0; } }
504`;
505
506/* ───────────────────────── Demo ───────────────────────── */
507
508export function Demo() {
509 return (
510 <div className="fd-grd-demo">
511 <style>{demoCss}</style>
512 <header className="fd-grd-demo-nav">
513 <a className="fd-grd-demo-logo" href="#top">
514 <svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
515 <circle cx="10" cy="10" r="8" fill="none" stroke="#5EEAB4" strokeWidth="1.6" />
516 <path d="M2 10h16M10 2c3 3 3 13 0 16M10 2c-3 3-3 13 0 16" fill="none" stroke="#5EEAB4" strokeWidth="1.2" />
517 </svg>
518 Gridline
519 </a>
520 <nav aria-label="Primary">
521 <a href="#platform">Platform</a>
522 <a href="#utilities">Utilities</a>
523 <a href="#owners">Asset owners</a>
524 <a href="#impact">Impact report</a>
525 </nav>
526 <a className="fd-grd-demo-cta" href="#demo">Book a demo</a>
527 </header>
528 <HeroClimateData />
529 </div>
530 );
531}
532
533const demoCss = `
534.fd-grd-demo { position: relative; margin: 0; padding: 0; min-height: 100vh; background: #030807; }
535.fd-grd-demo *, .fd-grd-demo *::before, .fd-grd-demo *::after { box-sizing: border-box; }
536.fd-grd-demo-nav { position: absolute; z-index: 10; top: 0; left: 0; right: 0; max-width: 1240px; height: 72px; margin: 0 auto; padding: 0 48px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
537.fd-grd-demo-logo { display: inline-flex; align-items: center; gap: 10px; margin: 0; color: #F2FBF7; text-decoration: none; font: 600 1.0625rem/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", sans-serif; letter-spacing: -0.02em; }
538.fd-grd-demo-logo svg { display: block; }
539.fd-grd-demo-nav nav { display: flex; gap: 30px; }
540.fd-grd-demo-nav nav a { color: rgba(214,236,228,0.65); text-decoration: none; font: 500 0.875rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; transition: color 120ms; }
541.fd-grd-demo-nav nav a:hover { color: #F2FBF7; }
542.fd-grd-demo-cta { display: inline-flex; align-items: center; height: 36px; padding: 0 16px; border-radius: 9999px; color: #F2FBF7; text-decoration: none; border: 1px solid rgba(94,234,180,0.3); font: 500 0.875rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; }
543.fd-grd-demo-nav a:focus-visible { outline: 2px solid rgba(94,234,180,.7); outline-offset: 2px; border-radius: 8px; }
544@media (max-width: 900px) { .fd-grd-demo-nav { padding: 0 20px; height: 64px; } .fd-grd-demo-nav nav { display: none; } }
545`;

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.