Skip to content

Fragrance Stage Hero

Product hero section: a perfume on a dark stage under a pointer-driven spotlight, with an edition picker that swaps the bottle.

  • Heroes
  • Beauty & fragrance
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (CSS mask-image and color-mix). Picker is a keyboard radiogroup.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-product-stage.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-product-stage.tsx
1/**
2 * HeroProductStage: a fragrance launch on a dark stage. The bottle stands on
3 * its pedestal under a spotlight that follows the pointer (a CSS radial mask
4 * over the photograph), a giant backdrop name rolls in per edition, specs
5 * tick to their new values and a swatch picker swaps the edition, photo,
6 * notes and ambient light.
7 *
8 * Props: house, editions (id, name, family, swatch, tint, src, notes, conc,
9 * sillage, volume, price, batch), defaultEdition, onReserve.
10 * CSS vars: --fd-pst-stage, --fd-pst-ink, --fd-pst-tint (per edition).
11 *
12 * Browser support: all evergreen browsers (mask-image, unprefixed and
13 * -webkit-). Pointer Events; on touch the light follows your finger.
14 * Motion: spotlight eased by a spring (k 380, c 30) in rAF, paused off-screen
15 * and in hidden tabs; numbers tick over 700ms. prefers-reduced-motion keeps
16 * the spotlight centred and swaps values instantly.
17 * Accessibility: the edition picker is a radiogroup with roving tabindex
18 * (arrow keys, Home, End). Spec changes are announced politely.
19 */
20import { useEffect, useRef, useState } from "react";
21import type { KeyboardEvent as ReactKeyboardEvent } from "react";
22
23export interface StageEdition {
24 id: string;
25 name: string;
26 family: string;
27 swatch: string;
28 tint: string;
29 src: string;
30 alt: string;
31 notes: { top: string; heart: string; base: string };
32 conc: number;
33 sillage: number;
34 volume: number;
35 price: number;
36 batch: string;
37}
38
39export interface HeroProductStageProps {
40 house?: string;
41 editions?: StageEdition[];
42 defaultEdition?: string;
43 currency?: string;
44 onReserve?: (edition: StageEdition) => void;
45}
46
47const EDITIONS: StageEdition[] = [
48 {
49 id: "noir", name: "Noir 01", family: "Leather, smoke", swatch: "#B7782C", tint: "#C98A3A",
50 src: "/img/lux/perfume-noir.webp", alt: "Amber glass bottle of Vérane Noir 01 on a dark stone plinth",
51 notes: { top: "Black tea, bergamot", heart: "Birch tar, iris", base: "Labdanum, suede" },
52 conc: 22, sillage: 9, volume: 50, price: 210, batch: "Batch 0412, Grasse, bottled 03.10.2026",
53 },
54 {
55 id: "dusk", name: "Dusk", family: "Rose, cassis", swatch: "#D45A9C", tint: "#C2508E",
56 src: "/img/lux/perfume-dusk.webp", alt: "Pink glass bottle of Vérane Dusk on a white plinth",
57 notes: { top: "Cassis, pink pepper", heart: "Damask rose, violet leaf", base: "Musk, cashmeran" },
58 conc: 18, sillage: 7, volume: 50, price: 185, batch: "Batch 0398, Grasse, bottled 12.09.2026",
59 },
60 {
61 id: "vert", name: "Vert", family: "Fig leaf, vetiver", swatch: "#4FAF84", tint: "#3E9B73",
62 src: "/img/lux/perfume-green.webp", alt: "Mint green glass bottle of Vérane Vert on a white plinth",
63 notes: { top: "Fig leaf, tomato vine", heart: "Galbanum, mint", base: "Haitian vetiver" },
64 conc: 16, sillage: 6, volume: 50, price: 175, batch: "Batch 0401, Grasse, bottled 19.09.2026",
65 },
66 {
67 id: "nord", name: "Nord", family: "Salt, juniper", swatch: "#6C92D8", tint: "#5B7FC4",
68 src: "/img/lux/perfume-north.webp", alt: "Pale blue glass bottle of Vérane Nord on a white plinth",
69 notes: { top: "Sea salt, juniper", heart: "Ambrette, sage", base: "Driftwood, ambroxan" },
70 conc: 15, sillage: 5, volume: 50, price: 175, batch: "Batch 0405, Grasse, bottled 26.09.2026",
71 },
72 {
73 id: "peche", name: "Pêche", family: "Peach, osmanthus", swatch: "#E2964F", tint: "#D9873F",
74 src: "/img/lux/perfume-peach.webp", alt: "Peach glass bottle of Vérane Pêche on a white plinth",
75 notes: { top: "White peach, neroli", heart: "Osmanthus, apricot skin", base: "Sandalwood, benzoin" },
76 conc: 20, sillage: 8, volume: 50, price: 195, batch: "Batch 0409, Grasse, bottled 01.10.2026",
77 },
78];
79
80function useTicker(value: number, reduce: boolean) {
81 const [shown, setShown] = useState(value);
82 const from = useRef(value);
83 useEffect(() => {
84 if (reduce) {
85 setShown(value);
86 from.current = value;
87 return;
88 }
89 const start = performance.now();
90 const a = from.current;
91 let raf = 0;
92 const step = (now: number) => {
93 const t = Math.min(1, (now - start) / 700);
94 const e = 1 - Math.pow(1 - t, 3);
95 const v = a + (value - a) * e;
96 setShown(v);
97 from.current = v;
98 if (t < 1) raf = requestAnimationFrame(step);
99 };
100 raf = requestAnimationFrame(step);
101 return () => cancelAnimationFrame(raf);
102 }, [value, reduce]);
103 return Math.round(shown);
104}
105
106export function HeroProductStage({
107 house = "Vérane",
108 editions = EDITIONS,
109 defaultEdition = "noir",
110 currency = "€",
111 onReserve,
112}: HeroProductStageProps) {
113 const [activeId, setActiveId] = useState(defaultEdition);
114 const [reduce, setReduce] = useState(false);
115 const [reserved, setReserved] = useState<string | null>(null);
116 const idx = Math.max(0, editions.findIndex((e) => e.id === activeId));
117 const ed = editions[idx];
118 const stageRef = useRef<HTMLDivElement>(null);
119 const radios = useRef<(HTMLButtonElement | null)[]>([]);
120
121 const conc = useTicker(ed.conc, reduce);
122 const sillage = useTicker(ed.sillage, reduce);
123 const price = useTicker(ed.price, reduce);
124
125 useEffect(() => {
126 setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
127 }, []);
128
129 // pointer-driven spotlight with a spring
130 useEffect(() => {
131 const el = stageRef.current;
132 if (!el) return;
133 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
134 let tx = 50, ty = 46, x = 50, y = 46, vx = 0, vy = 0;
135 let raf = 0;
136 let last = performance.now();
137 let visible = true;
138 let settled = true;
139 const onMove = (e: PointerEvent) => {
140 const photo = el.querySelector(".fd-pst-photo") as HTMLElement | null;
141 const r = (photo ?? el).getBoundingClientRect();
142 tx = Math.min(105, Math.max(-5, ((e.clientX - r.left) / r.width) * 100));
143 ty = Math.min(100, Math.max(0, ((e.clientY - r.top) / r.height) * 100));
144 kick();
145 };
146 const onLeave = () => {
147 tx = 50; ty = 46;
148 kick();
149 };
150 const frame = (now: number) => {
151 raf = 0;
152 const dt = Math.min(0.032, (now - last) / 1000);
153 last = now;
154 vx += (380 * (tx - x) - 30 * vx) * dt;
155 vy += (380 * (ty - y) - 30 * vy) * dt;
156 x += vx * dt;
157 y += vy * dt;
158 el.style.setProperty("--fd-pst-x", `${x.toFixed(2)}%`);
159 el.style.setProperty("--fd-pst-y", `${y.toFixed(2)}%`);
160 settled = Math.abs(tx - x) < 0.05 && Math.abs(ty - y) < 0.05 && Math.abs(vx) + Math.abs(vy) < 0.05;
161 if (!settled && visible && !document.hidden) raf = requestAnimationFrame(frame);
162 };
163 const kick = () => {
164 if (!raf && visible && !document.hidden) {
165 last = performance.now();
166 raf = requestAnimationFrame(frame);
167 }
168 };
169 const io = new IntersectionObserver(([e]) => {
170 visible = e.isIntersecting;
171 if (visible && !settled) kick();
172 });
173 io.observe(el);
174 const section = el.closest(".fd-pst") as HTMLElement | null;
175 const target = section ?? el;
176 target.addEventListener("pointermove", onMove);
177 target.addEventListener("pointerdown", onMove);
178 target.addEventListener("pointerleave", onLeave);
179 return () => {
180 cancelAnimationFrame(raf);
181 io.disconnect();
182 target.removeEventListener("pointermove", onMove);
183 target.removeEventListener("pointerdown", onMove);
184 target.removeEventListener("pointerleave", onLeave);
185 };
186 }, []);
187
188 const select = (i: number, focus = false) => {
189 const n = (i + editions.length) % editions.length;
190 setActiveId(editions[n].id);
191 setReserved(null);
192 if (focus) radios.current[n]?.focus();
193 };
194
195 const onKey = (e: ReactKeyboardEvent) => {
196 if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); select(idx + 1, true); }
197 else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); select(idx - 1, true); }
198 else if (e.key === "Home") { e.preventDefault(); select(0, true); }
199 else if (e.key === "End") { e.preventDefault(); select(editions.length - 1, true); }
200 };
201
202 return (
203 <section
204 className="fd-pst"
205 style={{ ["--fd-pst-tint" as string]: ed.tint }}
206 aria-label={`${house} ${ed.name}`}
207 >
208 <style>{css}</style>
209 <header className="fd-pst-nav">
210 <a className="fd-pst-logo" href="#top">
211 <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
212 <path d="M12 2.5 L21 21.5 H3 Z" fill="none" stroke="currentColor" strokeWidth="1.2" />
213 <path d="M12 9 V21.5" stroke="currentColor" strokeWidth="1.2" />
214 </svg>
215 <span>{house}</span>
216 </a>
217 <p className="fd-pst-season">Collection Ombre, Paris, Hiver 2026</p>
218 <a className="fd-pst-bag" href="#bag">Bag <span>{reserved ? 1 : 0}</span></a>
219 </header>
220
221 <div className="fd-pst-grid">
222 <div className="fd-pst-intro">
223 <p className="fd-pst-eyebrow">Eau de parfum, {String(idx + 1).padStart(2, "0")} of {String(editions.length).padStart(2, "0")}</p>
224 <h1 className="fd-pst-name">
225 <span className="fd-pst-house">{house}</span>
226 <span key={ed.id} className="fd-pst-roll">{ed.name}</span>
227 </h1>
228 <p className="fd-pst-family">{ed.family}</p>
229 <dl className="fd-pst-notes">
230 <div className="fd-pst-note"><dt>Top</dt><dd key={ed.id + "t"}>{ed.notes.top}</dd></div>
231 <div className="fd-pst-note"><dt>Heart</dt><dd key={ed.id + "h"}>{ed.notes.heart}</dd></div>
232 <div className="fd-pst-note"><dt>Base</dt><dd key={ed.id + "b"}>{ed.notes.base}</dd></div>
233 </dl>
234 </div>
235
236 <div ref={stageRef} className="fd-pst-stage">
237 <p className="fd-pst-backdrop" aria-hidden="true">
238 <span key={ed.id}>{ed.name.split(" ")[0]}</span>
239 </p>
240 <div className="fd-pst-beam" aria-hidden="true" />
241 <div className="fd-pst-photo">
242 {editions.map((e, i) => (
243 <img
244 key={e.id}
245 className={`fd-pst-img${i === idx ? " is-on" : ""}`}
246 src={e.src}
247 alt={i === idx ? e.alt : ""}
248 aria-hidden={i === idx ? undefined : true}
249 width={1200}
250 height={1500}
251 loading={i === 0 ? "eager" : "lazy"}
252 />
253 ))}
254 </div>
255 <div className="fd-pst-floor" aria-hidden="true" />
256 <p className="fd-pst-batch">{ed.batch}</p>
257 </div>
258
259 <div className="fd-pst-side">
260 <div className="fd-pst-picker">
261 <p className="fd-pst-label" id="fd-pst-ed-label">Edition</p>
262 <div className="fd-pst-swatches" role="radiogroup" aria-labelledby="fd-pst-ed-label" onKeyDown={onKey}>
263 {editions.map((e, i) => (
264 <button
265 key={e.id}
266 ref={(el) => {
267 radios.current[i] = el;
268 }}
269 type="button"
270 role="radio"
271 aria-checked={i === idx}
272 tabIndex={i === idx ? 0 : -1}
273 className="fd-pst-sw"
274 style={{ ["--fd-pst-sw" as string]: e.swatch }}
275 onClick={() => select(i)}
276 >
277 <span className="fd-pst-dot" aria-hidden="true" />
278 <span className="fd-pst-sw-name">{e.name}</span>
279 </button>
280 ))}
281 </div>
282 </div>
283
284 <dl className="fd-pst-specs" aria-live="polite">
285 <div className="fd-pst-spec"><dt>Concentration</dt><dd><b>{conc}</b>%</dd></div>
286 <div className="fd-pst-spec"><dt>Sillage</dt><dd><b>{sillage}</b> h</dd></div>
287 <div className="fd-pst-spec"><dt>Volume</dt><dd><b>{ed.volume}</b> ml</dd></div>
288 </dl>
289
290 <div className="fd-pst-buy">
291 <p className="fd-pst-price"><span>{currency}</span>{price}</p>
292 <button
293 type="button"
294 className={`fd-pst-cta${reserved === ed.id ? " is-done" : ""}`}
295 onClick={() => {
296 setReserved(ed.id);
297 onReserve?.(ed);
298 }}
299 >
300 {reserved === ed.id ? "Reserved, ships 14 Nov" : `Reserve ${ed.name}`}
301 </button>
302 <p className="fd-pst-fine">Free 2 ml sample of every edition with your order. Engraving included.</p>
303 </div>
304 </div>
305 </div>
306 </section>
307 );
308}
309
310const css = `
311.fd-pst, .fd-pst *, .fd-pst *::before, .fd-pst *::after { box-sizing: border-box; }
312.fd-pst :where(h1, p, a, dl, dt, dd, div, span, b, nav, header, button, img, svg) {
313 margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit;
314 letter-spacing: inherit; text-decoration: none;
315}
316.fd-pst {
317 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
318 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
319 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
320 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
321 --fd-pst-stage: #0C0A08;
322 --fd-pst-ink: #EFE7DA;
323 --fd-pst-dim: rgba(239,231,218,0.58);
324 --fd-pst-line: rgba(239,231,218,0.16);
325 --fd-pst-tint: #C98A3A;
326 --fd-pst-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
327 --fd-pst-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
328 --fd-pst-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
329 position: relative; display: flex; flex-direction: column; min-height: 880px; margin: 0; padding: 0 40px 32px;
330 overflow: hidden; isolation: isolate; touch-action: pan-y;
331 background:
332 radial-gradient(70% 55% at 50% 100%, color-mix(in srgb, var(--fd-pst-tint) 16%, transparent) 0%, transparent 70%),
333 var(--fd-pst-stage);
334 color: var(--fd-pst-ink); font: 400 15px/1.45 var(--fd-pst-sans);
335 transition: background var(--fd-dur-4) var(--fd-ease-in-out);
336}
337
338.fd-pst-nav { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 24px 0; border-bottom: 1px solid var(--fd-pst-line); position: relative; z-index: 3; }
339.fd-pst-logo { display: inline-flex; align-items: center; gap: 10px; color: var(--fd-pst-ink); }
340.fd-pst-logo svg { display: block; }
341.fd-pst-logo span { font: 400 20px/1 var(--fd-pst-serif); letter-spacing: 0.32em; text-transform: uppercase; }
342.fd-pst-season { font: 400 11px/1 var(--fd-pst-mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--fd-pst-dim); }
343.fd-pst-bag { justify-self: end; display: inline-flex; align-items: center; gap: 8px; font: 400 14px/1 var(--fd-pst-sans); color: var(--fd-pst-ink); }
344.fd-pst-bag span { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 9999px; border: 1px solid var(--fd-pst-line); font: 500 11px/1 var(--fd-pst-mono); }
345
346.fd-pst-grid { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr) minmax(0, 1fr); gap: 32px; align-items: stretch; padding-top: 28px; }
347
348/* intro */
349.fd-pst-intro { display: flex; flex-direction: column; justify-content: flex-end; position: relative; z-index: 2; padding-bottom: 8px; }
350.fd-pst-eyebrow { font: 400 11px/1 var(--fd-pst-mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--fd-pst-dim); margin-bottom: 18px; }
351.fd-pst-name { display: flex; flex-direction: column; font: 400 clamp(3rem, 5.4vw, 5.5rem)/0.92 var(--fd-pst-serif); letter-spacing: -0.03em; color: var(--fd-pst-ink); }
352.fd-pst-house { font: italic 400 0.42em/1.1 var(--fd-pst-serif); letter-spacing: 0; color: var(--fd-pst-dim); }
353.fd-pst-roll { display: block; animation: fd-pst-roll var(--fd-dur-4) var(--fd-ease-out) both; }
354@keyframes fd-pst-roll { from { opacity: 0; transform: translateY(0.35em); clip-path: inset(0 0 100% 0); } to { opacity: 1; transform: none; clip-path: inset(0 0 -10% 0); } }
355.fd-pst-family { margin-top: 14px; font: 400 17px/1.3 var(--fd-pst-sans); color: var(--fd-pst-tint); transition: color var(--fd-dur-3); filter: brightness(1.25); }
356.fd-pst-notes { margin-top: 32px; border-top: 1px solid var(--fd-pst-line); }
357.fd-pst-note { display: grid; grid-template-columns: 64px 1fr; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--fd-pst-line); }
358.fd-pst-note dt { font: 400 11px/1.6 var(--fd-pst-mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--fd-pst-dim); }
359.fd-pst-note dd { font: 400 15px/1.4 var(--fd-pst-serif); animation: fd-pst-fade var(--fd-dur-3) var(--fd-ease-out) both; }
360.fd-pst-note:nth-child(2) dd { animation-delay: 60ms; }
361.fd-pst-note:nth-child(3) dd { animation-delay: 120ms; }
362@keyframes fd-pst-fade { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: none; } }
363
364/* stage */
365.fd-pst-stage { --fd-pst-x: 50%; --fd-pst-y: 46%; position: relative; display: flex; align-items: center; justify-content: center; min-height: 600px; }
366.fd-pst-backdrop {
367 position: absolute; left: 50%; top: 8%; transform: translateX(-50%); z-index: 0; white-space: nowrap; pointer-events: none;
368 font: italic 400 clamp(8rem, 19vw, 18rem)/0.8 var(--fd-pst-serif); letter-spacing: -0.04em;
369 color: transparent; -webkit-text-stroke: 1px rgba(239,231,218,0.16);
370}
371.fd-pst-backdrop span { display: block; animation: fd-pst-back var(--fd-dur-4) var(--fd-ease-out) both; }
372@keyframes fd-pst-back { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: none; } }
373.fd-pst-beam {
374 position: absolute; top: -40px; bottom: 6%; left: 0; right: 0; z-index: 0; pointer-events: none;
375 background: linear-gradient(180deg, color-mix(in srgb, var(--fd-pst-tint) 22%, transparent), transparent 85%);
376 clip-path: polygon(calc(var(--fd-pst-x) - 5%) 0, calc(var(--fd-pst-x) + 5%) 0, 92% 100%, 8% 100%);
377 opacity: 0.55; mix-blend-mode: screen;
378}
379.fd-pst-photo {
380 position: relative; z-index: 1; width: min(100%, 470px); aspect-ratio: 4 / 5;
381 --fd-pst-vig: radial-gradient(ellipse 50% 50% at 50% 50%, #000 52%, rgba(0,0,0,0.5) 78%, transparent 100%);
382 --fd-pst-spot: radial-gradient(circle 250px at var(--fd-pst-x) var(--fd-pst-y), #000 0%, #000 34%, rgba(0,0,0,0.45) 68%, rgba(0,0,0,0.16) 100%);
383 -webkit-mask-image: var(--fd-pst-spot), var(--fd-pst-vig);
384 -webkit-mask-composite: source-in;
385 mask-image: var(--fd-pst-spot), var(--fd-pst-vig);
386 mask-composite: intersect;
387}
388.fd-pst-img {
389 position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover;
390 opacity: 0; transform: scale(1.04); filter: blur(6px);
391 transition: opacity var(--fd-dur-4) var(--fd-ease-in-out), transform var(--fd-dur-4) var(--fd-ease-out), filter var(--fd-dur-4) var(--fd-ease-out);
392}
393.fd-pst-img.is-on { opacity: 1; transform: none; filter: none; }
394.fd-pst-floor {
395 position: absolute; left: 10%; right: 10%; bottom: 7%; height: 70px; z-index: 0;
396 background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--fd-pst-tint) 38%, transparent), transparent 70%);
397 filter: blur(8px);
398}
399.fd-pst-batch { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; font: 400 11px/1 var(--fd-pst-mono); letter-spacing: 0.08em; color: var(--fd-pst-dim); z-index: 2; }
400
401/* side */
402.fd-pst-side { display: flex; flex-direction: column; justify-content: flex-end; gap: 28px; padding-bottom: 8px; position: relative; z-index: 2; }
403.fd-pst-label { font: 400 11px/1 var(--fd-pst-mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--fd-pst-dim); margin-bottom: 12px; }
404.fd-pst-swatches { display: flex; flex-direction: column; border-top: 1px solid var(--fd-pst-line); }
405.fd-pst-sw {
406 appearance: none; cursor: pointer; display: flex; align-items: center; gap: 14px; width: 100%; padding: 11px 0; text-align: left;
407 border: 0; border-bottom: 1px solid var(--fd-pst-line); background: none; color: var(--fd-pst-dim);
408 font: 400 15px/1.2 var(--fd-pst-sans); transition: color var(--fd-dur-1), padding var(--fd-dur-2) var(--fd-ease-out);
409}
410.fd-pst-sw:hover { color: var(--fd-pst-ink); padding-left: 4px; }
411.fd-pst-sw[aria-checked="true"] { color: var(--fd-pst-ink); }
412.fd-pst-dot {
413 position: relative; display: block; flex: none; width: 18px; height: 18px; border-radius: 50%; background: var(--fd-pst-sw);
414 box-shadow: inset 0 -3px 6px rgba(0,0,0,0.35), inset 0 2px 3px rgba(255,255,255,0.35);
415 transition: transform var(--fd-dur-2) var(--fd-spring);
416}
417.fd-pst-dot::after { content: ""; position: absolute; inset: -5px; border-radius: 50%; border: 1px solid var(--fd-pst-ink); opacity: 0; transform: scale(0.7); transition: opacity var(--fd-dur-2), transform var(--fd-dur-2) var(--fd-spring); }
418.fd-pst-sw[aria-checked="true"] .fd-pst-dot::after { opacity: 1; transform: none; }
419.fd-pst-sw:active .fd-pst-dot { transform: scale(0.86); transition-duration: var(--fd-dur-1); }
420.fd-pst-sw-name { flex: 1; }
421.fd-pst-specs { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--fd-pst-line); border-bottom: 1px solid var(--fd-pst-line); }
422.fd-pst-spec { padding: 12px 0; }
423.fd-pst-spec + .fd-pst-spec { padding-left: 14px; border-left: 1px solid var(--fd-pst-line); }
424.fd-pst-spec dt { font: 400 10px/1.2 var(--fd-pst-mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--fd-pst-dim); margin-bottom: 8px; }
425.fd-pst-spec dd { font: 400 15px/1 var(--fd-pst-mono); color: var(--fd-pst-dim); }
426.fd-pst-spec b { font: 400 28px/1 var(--fd-pst-serif); color: var(--fd-pst-ink); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
427.fd-pst-buy { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 12px 18px; }
428.fd-pst-price { font: 400 40px/1 var(--fd-pst-serif); letter-spacing: -0.025em; font-variant-numeric: tabular-nums; }
429.fd-pst-price span { font-size: 0.55em; vertical-align: 0.6em; margin-right: 2px; color: var(--fd-pst-dim); }
430.fd-pst-cta {
431 appearance: none; cursor: pointer; height: 52px; padding: 0 22px; border-radius: 9999px; border: 0;
432 background: var(--fd-pst-ink); color: #14110D; font: 600 15px/1 var(--fd-pst-sans); white-space: nowrap;
433 transition: background var(--fd-dur-2), color var(--fd-dur-2), transform var(--fd-dur-2) var(--fd-spring);
434}
435.fd-pst-cta:hover { background: #FFFFFF; }
436.fd-pst-cta:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
437.fd-pst-cta.is-done { background: transparent; color: var(--fd-pst-ink); box-shadow: inset 0 0 0 1px var(--fd-pst-ink); }
438.fd-pst-fine { grid-column: 1 / -1; font: 400 12px/1.45 var(--fd-pst-sans); color: var(--fd-pst-dim); }
439.fd-pst :focus-visible { outline: 2px solid var(--fd-pst-ink); outline-offset: 3px; border-radius: 4px; }
440
441@media (max-width: 1080px) {
442 .fd-pst-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
443 .fd-pst-intro { grid-column: 1; grid-row: 1; }
444 .fd-pst-stage { grid-column: 2; grid-row: 1 / 3; }
445 .fd-pst-side { grid-column: 1; grid-row: 2; }
446}
447@media (max-width: 760px) {
448 .fd-pst { padding: 0 16px 24px; min-height: 0; }
449 .fd-pst-nav { grid-template-columns: 1fr auto; padding: 18px 0; }
450 .fd-pst-season { display: none; }
451 .fd-pst-grid { grid-template-columns: minmax(0, 1fr); gap: 0; padding-top: 22px; }
452 .fd-pst-intro, .fd-pst-stage, .fd-pst-side { grid-column: 1; grid-row: auto; }
453 .fd-pst-notes { display: none; }
454 .fd-pst-name { flex-direction: row; align-items: baseline; gap: 12px; font-size: 3rem; }
455 .fd-pst-house { font-size: 0.5em; }
456 .fd-pst-stage { min-height: 0; padding: 4px 0 30px; margin-bottom: 18px; }
457 .fd-pst-photo { width: min(82%, 340px); }
458 .fd-pst-backdrop { top: 10%; font-size: 9rem; }
459 .fd-pst-side { gap: 20px; }
460 .fd-pst-swatches { flex-direction: row; gap: 0; justify-content: space-between; border: 0; }
461 .fd-pst-sw { flex-direction: column; gap: 8px; width: auto; flex: 1; padding: 10px 0; border: 0; font-size: 12px; text-align: center; }
462 .fd-pst-sw:hover { padding-left: 0; }
463 .fd-pst-dot { width: 26px; height: 26px; }
464 .fd-pst-buy { grid-template-columns: auto 1fr; }
465 .fd-pst-cta { width: 100%; }
466}
467@media (prefers-reduced-motion: reduce) {
468 .fd-pst, .fd-pst-img, .fd-pst-sw, .fd-pst-dot, .fd-pst-dot::after, .fd-pst-cta, .fd-pst-family { transition: none; }
469 .fd-pst-roll, .fd-pst-note dd, .fd-pst-backdrop span { animation: none; }
470 .fd-pst-img { filter: none; transform: none; }
471}
472@media (prefers-contrast: more) {
473 .fd-pst { --fd-pst-dim: rgba(239,231,218,0.85); --fd-pst-line: rgba(239,231,218,0.5); }
474}
475`;
476
477export function Demo() {
478 return <HeroProductStage />;
479}

More Heroes. Same file contract.

Ledger Card Hero, heroes preview: Fintech hero section with a CSS 3D metal card that tilts to the cursor, then flips and docks on scroll as…

Ledger Card Hero

Fintech hero section with a CSS 3D metal card that tilts to the cursor, then flips and docks on scroll as stats count up.

Agent Terminal Hero, heroes preview: AI hero section with a terminal that types a prompt and streams an agent's plan while a dashboard assembles…

Agent Terminal Hero

AI hero section with a terminal that types a prompt and streams an agent's plan while a dashboard assembles beside it.

Maison Editorial Hero, heroes preview: Editorial fashion hero section: a giant serif masthead over three campaign photos, revealed by clip-path wipes

Maison Editorial Hero

Editorial fashion hero section: a giant serif masthead over three campaign photos, revealed by clip-path wipes.

Questions about Fragrance Stage Hero

Run npx shadcn@latest add https://freedesignmd.com/r/hero-product-stage.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Product hero section: a perfume on a dark stage under a pointer-driven spotlight, with an edition picker that swaps the bottle. It is a free, MIT-licensed React heroes component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (CSS mask-image and color-mix). Picker is a keyboard radiogroup.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.