Bento Product Hero

A headline over a live bento grid where every tile runs a micro-interaction on a staggered loop, with a pointer spotlight.

  • Heroes
  • B2B SaaS
  • React only · 0 deps
  • 1 file
Full screen

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-saas-bento.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-saas-bento.tsx
1/**
2 * HeroSaasBento — a B2B SaaS hero: a centred headline over an animated bento
3 * grid. Each tile runs its own micro-interaction on a staggered loop: a revenue
4 * chart draws, notifications slide in, a policy toggle flips, an approval
5 * avatar stack fills, a sync ring completes and a command palette types.
6 * Tiles tilt toward the pointer with a spotlight border; on scroll the grid
7 * unfolds from a slight 3D lean to flat and the headline parallaxes.
8 *
9 * Props: title, lede, primaryCta, secondaryCta, logos (string[]).
10 * CSS vars: --fd-bto-bg, --fd-bto-ink, --fd-bto-accent.
11 *
12 * Browser support: all evergreen browsers.
13 * Motion: a single 7.2s timeline clock (rAF, paused off-screen / hidden tab)
14 * toggles step classes; transitions use the library curves. Pointer spotlight
15 * and tilt via CSS variables. Scroll via rAF + getBoundingClientRect.
16 * prefers-reduced-motion: every tile shows its finished state, no loop.
17 * Accessibility: tiles are an aria-hidden illustration with a visually
18 * hidden summary; the toggle shown is decorative (the real control lives in
19 * the product).
20 */
21import { useEffect, useRef, useState } from "react";
22import type { ReactNode } from "react";
23
24export interface HeroSaasBentoProps {
25 eyebrow?: ReactNode;
26 title?: ReactNode;
27 lede?: ReactNode;
28 primaryCta?: { label: string; href: string };
29 secondaryCta?: { label: string; href: string };
30 logos?: string[];
31}
32
33const CYCLE = 7200;
34
35export function HeroSaasBento({
36 eyebrow = "New · Workflows 2.0",
37 title = (
38 <>
39 Run procurement on <span className="fd-bto-hl">autopilot.</span>
40 </>
41 ),
42 lede = "Plinth routes every purchase request, enforces policy before money moves, and syncs approved spend to NetSuite in real time.",
43 primaryCta = { label: "Start free trial", href: "#trial" },
44 secondaryCta = { label: "Talk to sales", href: "#sales" },
45 logos = ["Northwind", "Halden", "Kestrel", "Oslo Freight", "Marlow & Finch"],
46}: HeroSaasBentoProps) {
47 const root = useRef<HTMLElement>(null);
48 const grid = useRef<HTMLDivElement>(null);
49 const head = useRef<HTMLDivElement>(null);
50 const [step, setStep] = useState(0); // 0..11 within a cycle
51 const [loop, setLoop] = useState(0);
52
53 useEffect(() => {
54 const el = root.current;
55 if (!el) return;
56 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
57 setStep(11);
58 return;
59 }
60 let raf = 0;
61 let visible = true;
62 let clock = 0;
63 let last = performance.now();
64 let prevStep = -1;
65 let prevLoop = 0;
66 const frame = (now: number) => {
67 raf = 0;
68 clock += Math.min(now - last, 64);
69 last = now;
70 const st = Math.floor(((clock % CYCLE) / CYCLE) * 12);
71 const lp = Math.floor(clock / CYCLE);
72 if (st !== prevStep) {
73 prevStep = st;
74 setStep(st);
75 }
76 if (lp !== prevLoop) {
77 prevLoop = lp;
78 setLoop(lp);
79 }
80 const r = el.getBoundingClientRect();
81 const p = Math.min(1, Math.max(0, -r.top / Math.max(1, r.height)));
82 const enter = Math.min(1, Math.max(0, 1 - r.top / Math.max(1, window.innerHeight)));
83 if (grid.current) grid.current.style.transform = `perspective(1600px) rotateX(${(Math.max(0, 14 - enter * 10 - p * 30)).toFixed(2)}deg) translate3d(0, ${(p * -80).toFixed(1)}px, 0) scale(${(0.97 + Math.min(1, p * 2) * 0.03).toFixed(4)})`;
84 if (head.current) {
85 head.current.style.transform = `translate3d(0, ${(p * 100).toFixed(1)}px, 0)`;
86 head.current.style.opacity = (1 - p * 1.8).toFixed(3);
87 }
88 if (visible && !document.hidden) raf = requestAnimationFrame(frame);
89 };
90 const start = () => {
91 if (!raf && visible && !document.hidden) {
92 last = performance.now();
93 raf = requestAnimationFrame(frame);
94 }
95 };
96 const io = new IntersectionObserver(([e]) => {
97 visible = e.isIntersecting;
98 if (visible) start();
99 });
100 io.observe(el);
101 document.addEventListener("visibilitychange", start);
102 start();
103 return () => {
104 cancelAnimationFrame(raf);
105 io.disconnect();
106 document.removeEventListener("visibilitychange", start);
107 };
108 }, []);
109
110 // spotlight + tilt per tile
111 useEffect(() => {
112 const g = grid.current;
113 if (!g || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
114 const onMove = (e: PointerEvent) => {
115 if (e.pointerType === "touch") return;
116 g.querySelectorAll<HTMLElement>(".fd-bto-tile").forEach((t) => {
117 const r = t.getBoundingClientRect();
118 t.style.setProperty("--mx", `${e.clientX - r.left}px`);
119 t.style.setProperty("--my", `${e.clientY - r.top}px`);
120 });
121 };
122 g.addEventListener("pointermove", onMove);
123 return () => g.removeEventListener("pointermove", onMove);
124 }, []);
125
126 const on = (n: number) => step >= n;
127 const cmd = "Approve all under $5k from Design";
128 const typed = step >= 7 ? cmd.slice(0, Math.min(cmd.length, (step - 6) * 9)) : "";
129
130 return (
131 <section ref={root} className="fd-bto" aria-label="Plinth procurement">
132 <style>{css}</style>
133 <div className="fd-bto-bgfx" aria-hidden="true" />
134 <div ref={head} className="fd-bto-head">
135 <a className="fd-bto-eyebrow" href="#whats-new">
136 <span className="fd-bto-eb-tag">{eyebrow}</span>
137 <span>See what’s new</span>
138 <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
139 </a>
140 <h1 className="fd-bto-h1">{title}</h1>
141 <p className="fd-bto-lede">{lede}</p>
142 <div className="fd-bto-ctas">
143 <a className="fd-bto-btn" href={primaryCta.href}>
144 {primaryCta.label}
145 </a>
146 <a className="fd-bto-btn2" href={secondaryCta.href}>
147 {secondaryCta.label}
148 </a>
149 </div>
150 </div>
151
152 <p className="fd-bto-sr">Product preview: spend chart, live approval notifications, policy toggles, approver chain, ERP sync status and a command bar.</p>
153
154 <div ref={grid} className="fd-bto-grid" aria-hidden="true">
155 {/* chart */}
156 <div className="fd-bto-tile fd-bto-t-chart">
157 <div className="fd-bto-th">
158 <span>Committed spend · Q4</span>
159 <b className="fd-bto-delta">−12.4% vs plan</b>
160 </div>
161 <p className="fd-bto-big">$2.41M</p>
162 <svg className={`fd-bto-chart ${on(1) ? "is-on" : ""}`} viewBox="0 0 400 120" preserveAspectRatio="none" key={`c${loop}`}>
163 <path className="fd-bto-plan" d="M0 96 L400 18" />
164 <path className="fd-bto-area" d="M0 110 C40 104 70 98 100 92 C140 84 160 88 200 74 C240 60 260 66 300 54 C340 42 370 46 400 38 L400 120 L0 120Z" />
165 <path className="fd-bto-line" pathLength="1" d="M0 110 C40 104 70 98 100 92 C140 84 160 88 200 74 C240 60 260 66 300 54 C340 42 370 46 400 38" />
166 <circle className="fd-bto-dot" cx="400" cy="38" r="4" />
167 </svg>
168 <div className="fd-bto-axis"><span>Oct</span><span>Nov</span><span>Dec</span></div>
169 </div>
170
171 {/* notifications */}
172 <div className="fd-bto-tile fd-bto-t-notes">
173 <div className="fd-bto-th"><span>Requests</span><b className="fd-bto-count">{on(5) ? 3 : on(3) ? 2 : on(1) ? 1 : 0} new</b></div>
174 <ul className="fd-bto-notes">
175 {[
176 ["Figma Enterprise", "Design · $18,400/yr", "#A259FF", 1],
177 ["AWS reserved instances", "Platform · $42,000", "#FF9900", 3],
178 ["Offsite — Lisbon", "People · $9,850", "#22C55E", 5],
179 ].map(([t, s, c, at]) => (
180 <li key={t as string} className={on(at as number) ? "is-in" : ""}>
181 <i style={{ background: c as string }} />
182 <div><b>{t}</b><span>{s}</span></div>
183 <em className={on((at as number) + 2) ? "is-ok" : ""}>{on((at as number) + 2) ? "Approved" : "Review"}</em>
184 </li>
185 ))}
186 </ul>
187 </div>
188
189 {/* toggle */}
190 <div className="fd-bto-tile fd-bto-t-toggle">
191 <div className="fd-bto-th"><span>Policy</span></div>
192 {[
193 ["Block personal cards", 2],
194 ["Require PO over $10k", 4],
195 ["Auto-renew SaaS", 99],
196 ].map(([l, at]) => (
197 <div key={l as string} className="fd-bto-row">
198 <span>{l}</span>
199 <span className={`fd-bto-sw ${on(at as number) ? "is-on" : ""}`}><i /></span>
200 </div>
201 ))}
202 </div>
203
204 {/* approvers */}
205 <div className="fd-bto-tile fd-bto-t-chain">
206 <div className="fd-bto-th"><span>Approval chain</span></div>
207 <div className="fd-bto-avs">
208 {[
209 ["MK", "#FDE68A"],
210 ["JL", "#BFDBFE"],
211 ["AR", "#FBCFE8"],
212 ["TS", "#BBF7D0"],
213 ].map(([i, c], k) => (
214 <span key={i} className={`fd-bto-av ${on(2 + k * 2) ? "is-ok" : ""}`} style={{ background: c }}>
215 {i}
216 <b>✓</b>
217 </span>
218 ))}
219 </div>
220 <p className="fd-bto-small">{on(8) ? "Fully approved in 2h 14m" : "Waiting on Finance"}</p>
221 </div>
222
223 {/* sync */}
224 <div className="fd-bto-tile fd-bto-t-sync">
225 <svg className="fd-bto-ring" viewBox="0 0 64 64">
226 <circle cx="32" cy="32" r="26" className="fd-bto-ring-bg" />
227 <circle cx="32" cy="32" r="26" className="fd-bto-ring-fg" pathLength="1" style={{ strokeDashoffset: 1 - Math.min(1, step / 9) }} />
228 </svg>
229 <div>
230 <b>{on(9) ? "Synced to NetSuite" : "Syncing ledger…"}</b>
231 <span>{Math.min(1284, Math.round((step / 9) * 1284)).toLocaleString("en-US")} / 1,284 lines</span>
232 </div>
233 </div>
234
235 {/* command */}
236 <div className="fd-bto-tile fd-bto-t-cmd">
237 <div className="fd-bto-cmd">
238 <svg viewBox="0 0 16 16" width="14" height="14"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
239 <span>{typed || <span className="fd-bto-ph">Ask Plinth…</span>}<i className="fd-bto-caret" /></span>
240 <kbd>⌘K</kbd>
241 </div>
242 </div>
243 </div>
244
245 <div className="fd-bto-logos">
246 <span>Trusted by finance teams at</span>
247 <ul>
248 {logos.map((l) => (
249 <li key={l}>{l}</li>
250 ))}
251 </ul>
252 </div>
253 </section>
254 );
255}
256
257const css = `
258.fd-bto, .fd-bto *, .fd-bto *::before, .fd-bto *::after { box-sizing: border-box; }
259.fd-bto {
260 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
261 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
262 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
263 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
264 --fd-bto-bg: #FAFAFB;
265 --fd-bto-ink: #0B0B0F;
266 --fd-bto-muted: #6B6B76;
267 --fd-bto-accent: #4F46E5;
268 --fd-bto-sans: "SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
269 position: relative; display: block; overflow: hidden; margin: 0; padding: 120px 32px 48px; min-height: 880px;
270 background: var(--fd-bto-bg); color: var(--fd-bto-ink); font: 400 1rem/1.5 var(--fd-bto-sans); isolation: isolate;
271}
272.fd-bto-bgfx { position: absolute; inset: 0; z-index: -1; pointer-events: none;
273 background: radial-gradient(40% 30% at 50% 0%, rgba(99, 102, 241, 0.16), transparent 70%), radial-gradient(30% 30% at 85% 55%, rgba(236, 72, 153, 0.08), transparent 70%), radial-gradient(30% 30% at 15% 60%, rgba(14, 165, 233, 0.08), transparent 70%);
274}
275.fd-bto-bgfx::after { content: ""; position: absolute; inset: 0; background-image: radial-gradient(rgba(11,11,15,0.08) 1px, transparent 1px); background-size: 22px 22px; -webkit-mask-image: radial-gradient(60% 50% at 50% 20%, #000, transparent); mask-image: radial-gradient(60% 50% at 50% 20%, #000, transparent); }
276.fd-bto-head { max-width: 820px; margin: 0 auto; text-align: center; will-change: transform, opacity; }
277.fd-bto-eyebrow {
278 display: inline-flex; align-items: center; gap: 10px; margin: 0 0 24px; padding: 4px 12px 4px 4px; border-radius: 9999px; text-decoration: none;
279 background: #fff; border: 1px solid rgba(11,11,15,0.08); box-shadow: 0 1px 2px rgba(11,11,15,0.04);
280 font: 500 0.8125rem/1 var(--fd-bto-sans); color: #3a3a44; animation: fd-bto-rise var(--fd-dur-4) var(--fd-ease-out) both;
281 transition: border-color var(--fd-dur-1);
282}
283.fd-bto-eyebrow:hover { border-color: rgba(79,70,229,0.35); }
284.fd-bto-eyebrow svg { display: block; }
285.fd-bto-eb-tag { padding: 5px 10px; border-radius: 9999px; background: rgba(79,70,229,0.1); color: var(--fd-bto-accent); font: 600 0.75rem/1 var(--fd-bto-sans); }
286.fd-bto-h1 { margin: 0; padding: 0; font: 600 clamp(2.75rem, 6.2vw, 5.25rem)/0.98 var(--fd-bto-sans); letter-spacing: -0.05em; color: var(--fd-bto-ink); text-wrap: balance; animation: fd-bto-blur 1000ms var(--fd-ease-out) 100ms both; }
287.fd-bto-hl { background: linear-gradient(92deg, #4F46E5, #9333EA 50%, #DB2777); -webkit-background-clip: text; background-clip: text; color: transparent; }
288.fd-bto-lede { margin: 22px auto 0; padding: 0; max-width: 34rem; font: 400 clamp(1rem, 1.25vw, 1.1875rem)/1.55 var(--fd-bto-sans); letter-spacing: -0.01em; color: var(--fd-bto-muted); text-wrap: pretty; animation: fd-bto-rise var(--fd-dur-4) var(--fd-ease-out) 240ms both; }
289.fd-bto-ctas { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; margin: 30px 0 0; animation: fd-bto-rise var(--fd-dur-4) var(--fd-ease-out) 340ms both; }
290.fd-bto-btn, .fd-bto-btn2 { display: inline-flex; align-items: center; margin: 0; height: 46px; padding: 0 20px; border-radius: 12px; text-decoration: none; font: 500 0.9375rem/1 var(--fd-bto-sans); letter-spacing: -0.01em; transition: transform var(--fd-dur-2) var(--fd-spring), box-shadow var(--fd-dur-2), background var(--fd-dur-1); }
291.fd-bto-btn { background: var(--fd-bto-ink); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 8px 20px -8px rgba(11,11,15,0.5); }
292.fd-bto-btn2 { background: #fff; color: var(--fd-bto-ink); box-shadow: 0 0 0 1px rgba(11,11,15,0.1), 0 2px 4px rgba(11,11,15,0.04); }
293.fd-bto-btn:active, .fd-bto-btn2:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
294.fd-bto-btn:focus-visible, .fd-bto-btn2:focus-visible, .fd-bto-eyebrow:focus-visible { outline: 2px solid rgba(79,70,229,0.7); outline-offset: 2px; }
295.fd-bto-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
296
297.fd-bto-grid {
298 display: grid; grid-template-columns: 1.4fr 1fr 1fr; grid-template-rows: auto auto auto; gap: 14px;
299 max-width: 1120px; margin: 56px auto 0; transform-origin: 50% 0%; will-change: transform;
300 animation: fd-bto-gridin 1200ms var(--fd-ease-out) 400ms both;
301}
302.fd-bto-tile {
303 --mx: -400px; --my: -400px;
304 position: relative; overflow: hidden; margin: 0; padding: 18px; border-radius: 20px; background: #fff;
305 box-shadow: 0 0 0 1px rgba(11,11,15,0.06), 0 1px 2px rgba(11,11,15,0.04), 0 12px 32px -16px rgba(11,11,15,0.14);
306}
307.fd-bto-tile::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none;
308 background: radial-gradient(240px circle at var(--mx) var(--my), rgba(99,102,241,0.55), transparent 60%);
309 -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
310.fd-bto-tile::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(300px circle at var(--mx) var(--my), rgba(99,102,241,0.05), transparent 60%); }
311.fd-bto-t-chart { grid-row: span 2; }
312.fd-bto-t-notes { grid-row: span 2; }
313.fd-bto-t-cmd { grid-column: 2 / -1; display: flex; align-items: center; padding: 10px; }
314.fd-bto-t-cmd .fd-bto-cmd { flex: 1; }
315.fd-bto-th { display: flex; justify-content: space-between; align-items: center; gap: 8px; font: 500 0.8125rem/1 var(--fd-bto-sans); color: var(--fd-bto-muted); }
316.fd-bto-delta { padding: 4px 8px; border-radius: 9999px; background: #ECFDF5; color: #047857; font: 600 0.6875rem/1 var(--fd-bto-sans); }
317.fd-bto-big { margin: 10px 0 0; padding: 0; font: 600 2.25rem/1 var(--fd-bto-sans); letter-spacing: -0.04em; color: var(--fd-bto-ink); font-variant-numeric: tabular-nums; }
318.fd-bto-chart { display: block; width: 100%; height: 150px; margin: 14px 0 0; overflow: visible; }
319.fd-bto-plan { fill: none; stroke: rgba(11,11,15,0.18); stroke-width: 1.2; stroke-dasharray: 4 5; vector-effect: non-scaling-stroke; }
320.fd-bto-line { fill: none; stroke: #4F46E5; stroke-width: 2.5; vector-effect: non-scaling-stroke; stroke-dasharray: 1; stroke-dashoffset: 1; }
321.fd-bto-area { fill: rgba(79,70,229,0.08); opacity: 0; }
322.fd-bto-dot { fill: #4F46E5; opacity: 0; transform-box: fill-box; transform-origin: center; }
323.fd-bto-chart.is-on .fd-bto-line { stroke-dashoffset: 0; transition: stroke-dashoffset 1600ms var(--fd-ease-out); }
324.fd-bto-chart.is-on .fd-bto-area { opacity: 1; transition: opacity var(--fd-dur-4) var(--fd-ease-out) 900ms; }
325.fd-bto-chart.is-on .fd-bto-dot { opacity: 1; animation: fd-bto-ping 2s var(--fd-ease-out) 1500ms infinite; transition: opacity var(--fd-dur-2) 1400ms; }
326.fd-bto-axis { display: flex; justify-content: space-between; margin: 8px 0 0; font: 500 0.6875rem/1 var(--fd-bto-sans); color: #9a9aa5; }
327
328.fd-bto-count { font: 600 0.6875rem/1 var(--fd-bto-sans); color: var(--fd-bto-accent); background: rgba(79,70,229,0.1); padding: 4px 8px; border-radius: 9999px; font-variant-numeric: tabular-nums; }
329.fd-bto-notes { display: flex; flex-direction: column; gap: 8px; margin: 14px 0 0; padding: 0; list-style: none; }
330.fd-bto-notes li {
331 display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; gap: 10px; align-items: center; margin: 0; padding: 12px; border-radius: 14px; background: #FAFAFB;
332 box-shadow: 0 0 0 1px rgba(11,11,15,0.05); opacity: 0; transform: translateX(24px) scale(0.98);
333 transition: opacity var(--fd-dur-3) var(--fd-ease-out), transform var(--fd-dur-4) var(--fd-spring);
334}
335.fd-bto-notes li.is-in { opacity: 1; transform: none; }
336.fd-bto-notes i { display: block; width: 8px; height: 8px; border-radius: 9999px; }
337.fd-bto-notes b { display: block; font: 600 0.8125rem/1.2 var(--fd-bto-sans); color: var(--fd-bto-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
338.fd-bto-notes span { display: block; margin-top: 2px; font: 400 0.75rem/1.2 var(--fd-bto-sans); color: var(--fd-bto-muted); }
339.fd-bto-notes em { font: 600 0.6875rem/1 var(--fd-bto-sans); font-style: normal; padding: 5px 8px; border-radius: 9999px; background: #FEF3C7; color: #92400E; transition: background var(--fd-dur-2), color var(--fd-dur-2); }
340.fd-bto-notes em.is-ok { background: #DCFCE7; color: #166534; }
341
342.fd-bto-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 14px 0 0; font: 500 0.8125rem/1.2 var(--fd-bto-sans); color: var(--fd-bto-ink); }
343.fd-bto-sw { position: relative; flex: none; width: 36px; height: 22px; border-radius: 9999px; background: #E4E4EA; transition: background var(--fd-dur-2) var(--fd-ease-in-out); }
344.fd-bto-sw i { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 9999px; background: #fff; box-shadow: 0 1px 3px rgba(11,11,15,0.25); transition: transform var(--fd-dur-3) var(--fd-spring); }
345.fd-bto-sw.is-on { background: #4F46E5; }
346.fd-bto-sw.is-on i { transform: translateX(14px); }
347
348.fd-bto-avs { display: flex; margin: 16px 0 0; }
349.fd-bto-av { position: relative; display: grid; place-items: center; width: 40px; height: 40px; margin-right: -8px; border-radius: 9999px; border: 2px solid #fff; font: 600 0.75rem/1 var(--fd-bto-sans); color: #1C1C22; filter: grayscale(1); opacity: 0.45; transition: filter var(--fd-dur-3), opacity var(--fd-dur-3), transform var(--fd-dur-3) var(--fd-spring); }
350.fd-bto-av b { position: absolute; right: -3px; bottom: -3px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 9999px; background: #22C55E; color: #fff; border: 2px solid #fff; font: 700 0.5rem/1 var(--fd-bto-sans); transform: scale(0); transition: transform var(--fd-dur-3) var(--fd-spring); }
351.fd-bto-av.is-ok { filter: none; opacity: 1; transform: translateY(-2px); }
352.fd-bto-av.is-ok b { transform: scale(1); }
353.fd-bto-small { margin: 14px 0 0; padding: 0; font: 400 0.75rem/1.3 var(--fd-bto-sans); color: var(--fd-bto-muted); }
354
355.fd-bto-t-sync { display: flex; align-items: center; gap: 14px; }
356.fd-bto-ring { display: block; flex: none; width: 52px; height: 52px; transform: rotate(-90deg); }
357.fd-bto-ring circle { fill: none; stroke-width: 5; }
358.fd-bto-ring-bg { stroke: #EEEEF2; }
359.fd-bto-ring-fg { stroke: #22C55E; stroke-linecap: round; stroke-dasharray: 1; transition: stroke-dashoffset var(--fd-dur-4) var(--fd-ease-out); }
360.fd-bto-t-sync b { display: block; font: 600 0.875rem/1.2 var(--fd-bto-sans); color: var(--fd-bto-ink); }
361.fd-bto-t-sync span { display: block; margin-top: 4px; font: 400 0.75rem/1.2 var(--fd-bto-sans); color: var(--fd-bto-muted); font-variant-numeric: tabular-nums; }
362
363.fd-bto-cmd { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 14px; border-radius: 12px; background: #FAFAFB; box-shadow: inset 0 0 0 1px rgba(11,11,15,0.06); color: #9a9aa5; font: 400 0.9375rem/1 var(--fd-bto-sans); }
364.fd-bto-cmd svg { display: block; flex: none; }
365.fd-bto-cmd > span { flex: 1; color: var(--fd-bto-ink); white-space: nowrap; overflow: hidden; }
366.fd-bto-ph { color: #9a9aa5; }
367.fd-bto-caret { display: inline-block; width: 2px; height: 1.1em; margin: 0 0 -0.2em 2px; background: var(--fd-bto-accent); animation: fd-bto-blink 1s steps(1) infinite; }
368.fd-bto-cmd kbd { padding: 4px 7px; border-radius: 6px; background: #fff; box-shadow: 0 0 0 1px rgba(11,11,15,0.1); font: 500 0.75rem/1 var(--fd-bto-sans); color: var(--fd-bto-muted); }
369
370.fd-bto-logos { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 14px 36px; max-width: 1120px; margin: 40px auto 0; animation: fd-bto-rise var(--fd-dur-4) var(--fd-ease-out) 900ms both; }
371.fd-bto-logos > span { font: 500 0.8125rem/1 var(--fd-bto-sans); color: #9a9aa5; }
372.fd-bto-logos ul { display: flex; flex-wrap: wrap; gap: 14px 36px; margin: 0; padding: 0; list-style: none; }
373.fd-bto-logos li { margin: 0; padding: 0; font: 700 1.0625rem/1 var(--fd-bto-sans); letter-spacing: -0.03em; color: #B4B4BE; }
374
375@keyframes fd-bto-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
376@keyframes fd-bto-blur { from { opacity: 0; filter: blur(12px); transform: translateY(8px); } to { opacity: 1; filter: none; transform: none; } }
377@keyframes fd-bto-gridin { from { opacity: 0; translate: 0 60px; } to { opacity: 1; translate: 0 0; } }
378@keyframes fd-bto-ping { 0% { transform: scale(1); } 50% { transform: scale(1.6); opacity: 0.6; } 100% { transform: scale(1); opacity: 1; } }
379@keyframes fd-bto-blink { 50% { opacity: 0; } }
380
381@media (max-width: 860px) {
382 .fd-bto { padding: 100px 16px 40px; min-height: 844px; }
383 .fd-bto-grid { grid-template-columns: 1fr 1fr; margin-top: 40px; gap: 10px; }
384 .fd-bto-t-chart { grid-column: 1 / -1; grid-row: auto; }
385 .fd-bto-t-notes { grid-column: 1 / -1; grid-row: auto; }
386 .fd-bto-t-toggle, .fd-bto-t-cmd { display: none; }
387 .fd-bto-t-sync { flex-direction: column; align-items: flex-start; }
388 .fd-bto-chart { height: 100px; }
389 .fd-bto-logos { display: none; }
390}
391@media (prefers-reduced-motion: reduce) {
392 .fd-bto *, .fd-bto *::before, .fd-bto *::after { animation: none !important; transition-duration: 1ms !important; transition-delay: 0ms !important; }
393}
394`;
395
396/* ───────────────────────── Demo ───────────────────────── */
397
398export function Demo() {
399 return (
400 <div className="fd-bto-demo">
401 <style>{demoCss}</style>
402 <header className="fd-bto-demo-nav">
403 <a className="fd-bto-demo-logo" href="#top">
404 <svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
405 <rect x="2" y="12" width="16" height="5" rx="1.5" fill="#0B0B0F" />
406 <rect x="5" y="7" width="10" height="4" rx="1.2" fill="#4F46E5" />
407 <rect x="8" y="3" width="4" height="3" rx="1" fill="#0B0B0F" />
408 </svg>
409 Plinth
410 </a>
411 <nav aria-label="Primary">
412 <a href="#product">Product</a>
413 <a href="#customers">Customers</a>
414 <a href="#integrations">Integrations</a>
415 <a href="#pricing">Pricing</a>
416 </nav>
417 <div className="fd-bto-demo-r">
418 <a href="#signin">Sign in</a>
419 <a className="fd-bto-demo-cta" href="#trial">Get started</a>
420 </div>
421 </header>
422 <HeroSaasBento />
423 </div>
424 );
425}
426
427const demoCss = `
428.fd-bto-demo { position: relative; margin: 0; padding: 0; min-height: 100vh; background: #FAFAFB; }
429.fd-bto-demo *, .fd-bto-demo *::before, .fd-bto-demo *::after { box-sizing: border-box; }
430.fd-bto-demo-nav { position: absolute; z-index: 10; top: 0; left: 0; right: 0; max-width: 1120px; height: 68px; margin: 0 auto; padding: 0 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
431.fd-bto-demo-nav .fd-bto-demo-logo { display: inline-flex; align-items: center; gap: 8px; margin: 0; color: #0B0B0F; text-decoration: none; font: 700 1.0625rem/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", sans-serif; letter-spacing: -0.03em; }
432.fd-bto-demo-logo svg { display: block; }
433.fd-bto-demo-nav nav { display: flex; gap: 28px; }
434.fd-bto-demo-nav a { color: #4a4a55; text-decoration: none; font: 500 0.875rem/1 ui-sans-serif, -apple-system, "Inter", sans-serif; }
435.fd-bto-demo-r { display: flex; align-items: center; gap: 18px; }
436.fd-bto-demo-r .fd-bto-demo-cta { display: inline-flex; align-items: center; height: 34px; padding: 0 14px; border-radius: 10px; background: #0B0B0F; color: #fff; }
437.fd-bto-demo-nav a:focus-visible { outline: 2px solid rgba(79,70,229,.7); outline-offset: 2px; border-radius: 6px; }
438@media (max-width: 860px) { .fd-bto-demo-nav { padding: 0 16px; } .fd-bto-demo-nav nav, .fd-bto-demo-r a:not(.fd-bto-demo-cta) { display: none; } }
439`;

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.