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
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 on3 * its pedestal under a spotlight that follows the pointer (a CSS radial mask4 * over the photograph), a giant backdrop name rolls in per edition, specs5 * 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 and13 * -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-screen15 * and in hidden tabs; numbers tick over 700ms. prefers-reduced-motion keeps16 * the spotlight centred and swaps values instantly.17 * Accessibility: the edition picker is a radiogroup with roving tabindex18 * (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 spring130 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 <section204 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 <img244 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 <button265 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 <button293 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.
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.


