Noir. Luxury Fragrance landing page.

A cinematic luxury-fragrance landing page. The hero is a procedural smoke / ink fluid on canvas (about a thousand particles on a drifting noise field) that the cursor drags, swirls and feeds with amber ink, under a giant italic serif title that rises letter by letter. Then a pinned manifesto that lights up word by word, a pinned SVG flacon with two bands of light sweeping across the glass as you scroll, a pinned horizontal journey through top, heart and base notes each with its own colour atmosphere, a pinned 'discover your scent' quiz with an animated result (name, bars, a flacon that fills with its own colour), hover-lift collection rows and a giant outlined wordmark footer. Black, bone and one amber; system serif, no images or web fonts.

  • Cursor-stirred smoke canvas
  • Letter-rise title
  • Word-by-word manifesto
  • Scroll light sweep on SVG bottle
  • Pinned horizontal notes
  • Crossfading colour atmospheres
  • Pinned quiz with animated result
  • Hover-lift collection rows
Full screen

The preview is interactive — scroll inside it to run the animations.

The whole page. One file, nothing left out.

817 lines · 56.5 KB · imports React only. This is the exact file behind the preview above — every section, interaction and style.

  1. 01Save it as noir-parfum.tsx in any React 18+ project.
  2. 02Render <Page /> on a route of its own — it brings its own nav and footer.
  3. 03Swap the copy and tokens. Keep the DESIGN.md next to it so your AI agent follows the system.
noir-parfum.tsx
1/**
2 * Noir — luxury perfume house landing page (Maison Vérane).
3 *
4 * Cinematic black, bone and a single amber. Giant italic serif type; no
5 * web fonts or network. The hero flacon is a pre-rendered photograph
6 * (Demo image: /img/lux/perfume-noir.webp, included in the repo's public/img/lux —
7 * copy it next to your page or swap in your own); the smaller flacons stay SVG so they can be tinted.
8 * The smoke is a canvas of ~1k particles on
9 * a drifting noise field that the cursor stirs.
10 *
11 * Motion
12 * - Hero: procedural smoke / ink fluid. Pointer velocity drags particles and
13 * adds a swirl; the title rises letter by letter out of a mask.
14 * - Manifesto: pinned, a giant italic sentence lights up word by word.
15 * - Flacon: pinned SVG bottle, two bands of light sweep across the glass as
16 * you scroll while the liquid glows and the bottle tilts; captions arrive
17 * in turn over an outlined background word.
18 * - Notes: pinned horizontal scroll through top / heart / base, each with its
19 * own colour atmosphere that crossfades; giant words counter-parallax.
20 * - Quiz: a pinned "discover your scent" with an animated result (name rises
21 * letter by letter, bars fill, the flacon fills with its own colour).
22 * - Collection rows with hover-lift bottles; reveal-on-scroll elsewhere.
23 *
24 * Reduced motion: smoke is a single pre-rendered frame, nothing pins, the
25 * notes stack vertically, every word and caption is fully visible.
26 * SSR-safe: no window/document access during render.
27 */
28import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from "react";
29
30const sv = (o: Record<string, string | number>) => o as CSSProperties;
31const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
32const prefersReduced = () => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
33const NOOP = (e: { preventDefault: () => void }) => e.preventDefault();
34
35/* ------------------------------------------------------------------ */
36/* Data */
37/* ------------------------------------------------------------------ */
38const MANIFESTO = "Some things are not worn. They are remembered — a coat left on a chair, a door closing in another room, the last warm hour of a long day.";
39const AMBER_WORDS = new Set(["remembered", "warm", "hour"]);
40
41const NOTES = [
42 { k: "Top", n: "I", when: "0 – 15 minutes", word: "Top", line: "The first breath. Bright, cold, gone before you can hold it.", atm: "#9DB5A6", list: [["Black bergamot", "Calabria, cold-pressed at dawn"], ["Pink pepper", "Sharp, almost metallic"], ["Green cardamom", "Crushed between the fingers"]] },
43 { k: "Heart", n: "II", when: "15 minutes – 3 hours", word: "Heart", line: "The conversation. Velvet petals over a low smoulder.", atm: "#B04A5C", list: [["Rose absolute", "Bulgarian, steam-distilled twice"], ["Labdanum", "Resin of the cistus, dark and leathery"], ["Smoked iris", "Orris butter touched with birch tar"]] },
44 { k: "Base", n: "III", when: "3 – 12 hours", word: "Base", line: "The memory. What stays on the collar long after you leave.", atm: "#E0A04A", list: [["Amber accord", "Warm, resinous, faintly sweet"], ["Aged oud", "Eleven years in oak"], ["Tonka & vetiver", "Dry hay, smoke, the end of summer"]] },
45];
46
47const QUIZ = [
48 { q: "Where does the evening find you?", o: [
49 ["A velvet bar, past midnight", [3, 1, 0, 0]], ["A coastal road at dusk", [0, 0, 3, 1]], ["An empty cathedral", [1, 0, 1, 3]], ["A garden after the rain", [0, 3, 1, 1]]] },
50 { q: "Choose a texture.", o: [
51 ["Suede, worn soft", [3, 1, 0, 0]], ["Raw silk", [0, 3, 0, 1]], ["Cold stone", [0, 0, 2, 3]], ["Linen, line-dried", [0, 0, 3, 1]]] },
52 { q: "How long should it linger?", o: [
53 ["A whisper, three hours", [0, 0, 2, 2]], ["An evening, eight hours", [1, 2, 1, 1]], ["A memory, twelve and more", [3, 2, 0, 0]], ["Until someone asks", [2, 1, 0, 2]]] },
54] as { q: string; o: [string, number[]][] }[];
55
56const SCENTS = [
57 { id: "nox", num: "19", name: "Nox Ambre", tint: "#E0A04A", desc: "Warm, resinous and unhurried. Amber and aged oud in a slow, dark dance. A scent for rooms with the lights turned low.", bars: [92, 78, 96], notes: ["Black bergamot, pink pepper", "Rose absolute, labdanum", "Amber, oud, tonka"] },
58 { id: "rose", num: "22", name: "Rose Fumée", tint: "#B04A5C", desc: "A rose that has been near a fire. Smoked petals and birch tar over a floor of patchouli. Romantic, a little dangerous.", bars: [74, 88, 82], notes: ["Saffron, cardamom", "Rose absolute, birch tar", "Patchouli, guaiac wood"] },
59 { id: "vet", num: "31", name: "Vétiver Pâle", tint: "#9DB5A6", desc: "Cool, mineral and clean-edged. Vetiver root and cypress under a flash of grapefruit. Salt air and linen.", bars: [58, 46, 64], notes: ["Grapefruit zest, juniper", "Vetiver, cypress", "Oakmoss, white musk"] },
60 { id: "iris", num: "08", name: "Iris Cendre", tint: "#C9C2D6", desc: "Powdery, grey and quietly austere. Orris butter, violet leaf and cold ash. The smell of stone after a long silence.", bars: [62, 54, 70], notes: ["Aldehydes, mandarin", "Orris butter, violet leaf", "Cedar, ash, sandalwood"] },
61];
62
63const COLLECTION = [
64 { n: "Nox Ambre", no: "Nº 19", notes: "Amber · Oud · Tonka", price: "€265", tint: "#E0A04A" },
65 { n: "Rose Fumée", no: "Nº 22", notes: "Smoked rose · Birch tar · Patchouli", price: "€265", tint: "#B04A5C" },
66 { n: "Vétiver Pâle", no: "Nº 31", notes: "Vetiver · Cypress · Grapefruit", price: "€245", tint: "#9DB5A6" },
67 { n: "Iris Cendre", no: "Nº 08", notes: "Orris · Violet leaf · Ash", price: "€285", tint: "#C9C2D6" },
68];
69
70/* ------------------------------------------------------------------ */
71/* Noise for the smoke */
72/* ------------------------------------------------------------------ */
73const hash2 = (x: number, y: number) => {
74 let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
75 h = Math.imul(h ^ (h >>> 13), 1274126177);
76 return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
77};
78const vnoise = (x: number, y: number) => {
79 const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
80 const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
81 const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);
82 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
83};
84
85/* ------------------------------------------------------------------ */
86/* Flacon */
87/* ------------------------------------------------------------------ */
88const BODY = "M104 252Q104 226 130 226H270Q296 226 296 252V556Q296 586 266 586H134Q104 586 104 556Z";
89const LIQ = "M118 270Q118 264 126 264H274Q282 264 282 270V550Q282 572 262 572H138Q118 572 118 550Z";
90
91function Defs() {
92 return (
93 <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true" focusable="false">
94 <defs>
95 <linearGradient id="nrg-glass" x1="0" y1="0" x2="1" y2="0">
96 <stop offset="0" stopColor="#1d1a16" /><stop offset=".12" stopColor="#0d0c0a" /><stop offset=".55" stopColor="#14120f" /><stop offset=".9" stopColor="#0a0908" /><stop offset="1" stopColor="#24201a" />
97 </linearGradient>
98 <linearGradient id="nrg-shade" x1="0" y1="0" x2="0" y2="1">
99 <stop offset="0" stopColor="#fff" stopOpacity=".22" /><stop offset=".25" stopColor="#fff" stopOpacity="0" /><stop offset=".7" stopColor="#000" stopOpacity="0" /><stop offset="1" stopColor="#000" stopOpacity=".55" />
100 </linearGradient>
101 <linearGradient id="nrg-sweep" x1="0" y1="0" x2="1" y2="0">
102 <stop offset="0" stopColor="#fff" stopOpacity="0" /><stop offset=".5" stopColor="#fff" stopOpacity=".7" /><stop offset="1" stopColor="#fff" stopOpacity="0" />
103 </linearGradient>
104 <linearGradient id="nrg-cap" x1="0" y1="0" x2="1" y2="0">
105 <stop offset="0" stopColor="#2a2621" /><stop offset=".18" stopColor="#0b0a09" /><stop offset=".5" stopColor="#1b1814" /><stop offset=".8" stopColor="#090807" /><stop offset="1" stopColor="#2b2721" />
106 </linearGradient>
107 <linearGradient id="nrg-metal" x1="0" y1="0" x2="1" y2="0">
108 <stop offset="0" stopColor="#7a5524" /><stop offset=".35" stopColor="#f0c27a" /><stop offset=".6" stopColor="#b57f34" /><stop offset="1" stopColor="#5e3f17" />
109 </linearGradient>
110 <radialGradient id="nrg-glow"><stop offset="0" stopColor="#E0A04A" stopOpacity=".9" /><stop offset=".5" stopColor="#E0A04A" stopOpacity=".25" /><stop offset="1" stopColor="#E0A04A" stopOpacity="0" /></radialGradient>
111 <clipPath id="nrg-photo"><rect x="104" y="226" width="192" height="360" rx="30" /><rect x="146" y="120" width="108" height="130" rx="10" /></clipPath>
112 <clipPath id="nrg-clip">
113 <path d={BODY} /><rect x="176" y="176" width="48" height="54" /><rect x="164" y="162" width="72" height="20" rx="3" /><rect x="146" y="48" width="108" height="118" rx="10" />
114 </clipPath>
115 </defs>
116 </svg>
117 );
118}
119
120type FlaconProps = {
121 tint?: string; big?: boolean; level?: number; className?: string;
122 sweepRef?: (el: SVGRectElement | null) => void; sweep2Ref?: (el: SVGRectElement | null) => void;
123 glowRef?: (el: SVGCircleElement | null) => void; tiltRef?: (el: SVGGElement | null) => void;
124};
125function Flacon({ tint = "#E0A04A", big = false, level = 1, className, sweepRef, sweep2Ref, glowRef, tiltRef }: FlaconProps) {
126 if (big)
127 return (
128 <svg className={className} viewBox="0 0 400 640" role="img" aria-label="Vérane Nº 19 flacon: amber glass, black lacquer cap, photographed in golden light" style={{ overflow: "visible" }}>
129 <circle ref={glowRef} cx="200" cy="420" r="260" fill="url(#nrg-glow)" opacity={0.3} style={{ mixBlendMode: "screen" }} />
130 <g ref={tiltRef}>
131 <image
132 href="/img/lux/perfume-noir.webp"
133 x="-70" y="20" width="540" height="675"
134 preserveAspectRatio="xMidYMid slice"
135 style={{ WebkitMaskImage: "radial-gradient(ellipse 62% 60% at 50% 50%, #000 52%, transparent 100%)", maskImage: "radial-gradient(ellipse 62% 60% at 50% 50%, #000 52%, transparent 100%)" }}
136 />
137 <g clipPath="url(#nrg-photo)" style={{ mixBlendMode: "screen" }}>
138 <rect ref={sweepRef} x="0" y="0" width="90" height="640" fill="url(#nrg-sweep)" transform="translate(-200 0) skewX(-16)" />
139 <rect ref={sweep2Ref} x="0" y="0" width="26" height="640" fill="url(#nrg-sweep)" opacity=".8" transform="translate(-200 0) skewX(-16)" />
140 </g>
141 </g>
142 </svg>
143 );
144 return (
145 <svg className={className} viewBox="0 0 400 640" role="img" aria-label="Vérane flacon, hand-blown glass with an oak cap" style={{ overflow: "visible" }}>
146 <circle ref={glowRef} cx="200" cy="420" r="260" fill="url(#nrg-glow)" opacity={big ? 0.3 : 0.18} style={{ mixBlendMode: "screen" }} />
147 <g ref={tiltRef}>
148 <ellipse cx="200" cy="600" rx="150" ry="14" fill="#000" opacity=".7" />
149 {/* glass */}
150 <path d={BODY} fill="url(#nrg-glass)" stroke="#E9E2D3" strokeOpacity=".38" strokeWidth="1.2" />
151 {/* liquid */}
152 <g className="nr-liq" style={{ transform: `scaleY(${level})`, transformOrigin: "200px 572px", transformBox: "view-box" } as CSSProperties}>
153 <path d={LIQ} fill={tint} fillOpacity=".92" />
154 <path d={LIQ} fill="url(#nrg-shade)" />
155 <ellipse cx="200" cy="266" rx="82" ry="4" fill="#fff" opacity=".35" />
156 </g>
157 <path d="M104 252Q104 226 130 226H270Q296 226 296 252V556Q296 586 266 586H134Q104 586 104 556Z" fill="url(#nrg-shade)" opacity=".35" />
158 <rect x="112" y="240" width="5" height="330" rx="2.5" fill="#fff" opacity=".16" />
159 <rect x="104" y="548" width="192" height="3" fill="#fff" opacity=".08" />
160 {/* neck + collar + cap */}
161 <rect x="176" y="176" width="48" height="54" fill="url(#nrg-glass)" stroke="#E9E2D3" strokeOpacity=".3" />
162 <rect x="164" y="162" width="72" height="20" rx="3" fill="url(#nrg-metal)" />
163 <rect x="146" y="48" width="108" height="118" rx="10" fill="url(#nrg-cap)" stroke="#E9E2D3" strokeOpacity=".3" />
164 <path d="M172 52V162M200 52V162M228 52V162" stroke="#E9E2D3" strokeOpacity=".08" />
165 <rect x="150" y="56" width="4" height="100" rx="2" fill="#fff" opacity=".18" />
166 {/* label */}
167 <rect x="134" y="344" width="132" height="140" fill="none" stroke="#E9E2D3" strokeOpacity={big ? 0.45 : 0.35} strokeWidth=".8" />
168 {big && (
169 <>
170 <text x="200" y="372" textAnchor="middle" fontFamily="inherit" fontSize="10" letterSpacing="5" fill="#E9E2D3" fillOpacity=".85">VÉRANE</text>
171 <text x="200" y="430" textAnchor="middle" fontFamily="inherit" fontStyle="italic" fontSize="46" fill="#E9E2D3">Nº 19</text>
172 <line x1="176" y1="446" x2="224" y2="446" stroke="#E0A04A" strokeWidth="1" />
173 <text x="200" y="464" textAnchor="middle" fontFamily="inherit" fontSize="8" letterSpacing="3.5" fill="#E9E2D3" fillOpacity=".7">NOX AMBRE</text>
174 <text x="200" y="476" textAnchor="middle" fontFamily="inherit" fontSize="6" letterSpacing="2.5" fill="#E9E2D3" fillOpacity=".5">EAU DE PARFUM · 50 ML</text>
175 </>
176 )}
177 {!big && <line x1="170" y1="414" x2="230" y2="414" stroke="#E9E2D3" strokeOpacity=".5" strokeWidth=".8" />}
178 {/* light */}
179 {big && (
180 <g clipPath="url(#nrg-clip)" style={{ mixBlendMode: "screen" }}>
181 <rect ref={sweepRef} x="0" y="0" width="90" height="640" fill="url(#nrg-sweep)" transform="translate(-200 0) skewX(-16)" />
182 <rect ref={sweep2Ref} x="0" y="0" width="26" height="640" fill="url(#nrg-sweep)" opacity=".8" transform="translate(-200 0) skewX(-16)" />
183 </g>
184 )}
185 </g>
186 </svg>
187 );
188}
189
190/* ------------------------------------------------------------------ */
191/* Page */
192/* ------------------------------------------------------------------ */
193export function Page() {
194 const rootRef = useRef<HTMLDivElement>(null);
195 const heroRef = useRef<HTMLElement>(null);
196 const cvsRef = useRef<HTMLCanvasElement>(null);
197 const heroTxtRef = useRef<HTMLDivElement>(null);
198 const manRef = useRef<HTMLElement>(null);
199 const wordRefs = useRef<(HTMLSpanElement | null)[]>([]);
200 const botRef = useRef<HTMLElement>(null);
201 const sweepRef = useRef<SVGRectElement | null>(null);
202 const sweep2Ref = useRef<SVGRectElement | null>(null);
203 const glowRef = useRef<SVGCircleElement | null>(null);
204 const tiltRef = useRef<SVGGElement | null>(null);
205 const capRefs = useRef<(HTMLDivElement | null)[]>([]);
206 const bgWordRef = useRef<HTMLDivElement>(null);
207 const notesRef = useRef<HTMLElement>(null);
208 const trackRef = useRef<HTMLDivElement>(null);
209 const atmRefs = useRef<(HTMLDivElement | null)[]>([]);
210 const panelRefs = useRef<(HTMLDivElement | null)[]>([]);
211 const fillRef = useRef<HTMLSpanElement>(null);
212 const quizRef = useRef<HTMLElement>(null);
213 const qringRef = useRef<SVGGElement>(null);
214
215 const [note, setNote] = useState(0);
216 const [step, setStep] = useState(0);
217 const [picks, setPicks] = useState<number[][]>([]);
218 const [go, setGo] = useState(false);
219 const [reserved, setReserved] = useState(false);
220 const [navSolid, setNavSolid] = useState(false);
221 const [email, setEmail] = useState("");
222 const [joined, setJoined] = useState(false);
223
224 const words = MANIFESTO.split(" ");
225
226 /* reveals */
227 useEffect(() => {
228 const root = rootRef.current; if (!root) return;
229 root.classList.add("nr-js");
230 const els = Array.from(root.querySelectorAll<HTMLElement>("[data-rv]"));
231 if (typeof IntersectionObserver === "undefined") { els.forEach((e) => e.classList.add("in")); return; }
232 const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }), { rootMargin: "0px 0px -10% 0px" });
233 els.forEach((e) => io.observe(e));
234 return () => io.disconnect();
235 }, []);
236
237 /* ───── smoke ───── */
238 useEffect(() => {
239 const cvs = cvsRef.current, hero = heroRef.current; if (!cvs || !hero) return;
240 const ctx = cvs.getContext("2d"); if (!ctx) return;
241 const rm = prefersReduced();
242 const SC = 0.5;
243 let W = 0, H = 0, N = 0;
244 let xs = new Float32Array(0), ys = new Float32Array(0), pxs = new Float32Array(0), pys = new Float32Array(0), life = new Float32Array(0), kind = new Uint8Array(0);
245 let mx = -9999, my = -9999, lx = -9999, ly = -9999, mvx = 0, mvy = 0, hot = 0;
246 let visible = true, raf = 0, last = performance.now(), T = 0;
247 const R = 120;
248
249 const spawn = (i: number, anywhere: boolean) => {
250 if (anywhere || Math.random() < 0.3) { xs[i] = Math.random() * W; ys[i] = Math.random() * H; }
251 else { xs[i] = W * 0.5 + (Math.random() + Math.random() + Math.random() - 1.5) * W * 0.32; ys[i] = H + 8; }
252 pxs[i] = xs[i]; pys[i] = ys[i];
253 life[i] = 220 + Math.random() * 380;
254 kind[i] = Math.random() < 0.16 ? 1 : 0;
255 };
256 const resize = () => {
257 W = Math.ceil(hero.clientWidth * SC); H = Math.ceil(hero.clientHeight * SC);
258 cvs.width = W; cvs.height = H;
259 ctx.fillStyle = "#070605"; ctx.fillRect(0, 0, W, H);
260 N = Math.round(clamp((W * H) / 520, 420, 1500));
261 xs = new Float32Array(N); ys = new Float32Array(N); pxs = new Float32Array(N); pys = new Float32Array(N); life = new Float32Array(N); kind = new Uint8Array(N);
262 for (let i = 0; i < N; i++) spawn(i, true);
263 };
264 const step = (dt: number, fade: number) => {
265 T += dt * 0.016;
266 ctx.globalCompositeOperation = "source-over";
267 ctx.fillStyle = `rgba(7,6,5,${fade})`; ctx.fillRect(0, 0, W, H);
268 ctx.globalCompositeOperation = "lighter";
269 ctx.lineWidth = 1.3; ctx.lineCap = "round";
270 const sp = Math.hypot(mvx, mvy);
271 // pointer injects fresh amber ink
272 if (hot > 0.02 && sp > 0.6) { for (let k = 0; k < 10; k++) { const i = (Math.random() * N) | 0; xs[i] = mx + (Math.random() - 0.5) * 16; ys[i] = my + (Math.random() - 0.5) * 16; pxs[i] = xs[i]; pys[i] = ys[i]; kind[i] = Math.random() < 0.55 ? 1 : 0; life[i] = 120 + Math.random() * 160; } }
273 for (let i = 0; i < N; i++) {
274 let x = xs[i], y = ys[i];
275 const a = vnoise(x * 0.0042 + T * 0.35, y * 0.0042 - T * 0.2) * 12.5 + vnoise(x * 0.011 - T * 0.5, y * 0.011 + T * 0.3) * 3;
276 let vx = Math.cos(a) * 0.85, vy = Math.sin(a) * 0.85 - 0.42;
277 let near = 0;
278 if (hot > 0.02) {
279 const dx = x - mx, dy = y - my, d2 = dx * dx + dy * dy;
280 if (d2 < R * R) {
281 const d = Math.sqrt(d2) + 0.001, f = (1 - d / R) * (1 - d / R) * hot;
282 vx += mvx * f * 0.45 - (dy / d) * f * 2.2; vy += mvy * f * 0.45 + (dx / d) * f * 2.2; near = f;
283 }
284 }
285 x += vx * dt; y += vy * dt;
286 const al = (kind[i] ? 0.2 : 0.15) * (1 + near * 1.4) * clamp(life[i] / 60);
287 ctx.strokeStyle = kind[i] ? `rgba(224,160,74,${al})` : `rgba(233,226,211,${al})`;
288 ctx.beginPath(); ctx.moveTo(pxs[i], pys[i]); ctx.lineTo(x, y); ctx.stroke();
289 pxs[i] = x; pys[i] = y; xs[i] = x; ys[i] = y;
290 life[i] -= dt;
291 if (life[i] <= 0 || x < -20 || x > W + 20 || y < -20 || y > H + 20) spawn(i, false);
292 }
293 mvx *= 0.9; mvy *= 0.9; hot *= 0.985;
294 };
295 const frame = (now: number) => {
296 raf = requestAnimationFrame(frame);
297 const dt = Math.min(2.2, (now - last) / 16.67); last = now;
298 if (!visible) return;
299 step(dt, 0.05);
300 };
301 const onMove = (e: PointerEvent) => {
302 const r = hero.getBoundingClientRect();
303 const x = (e.clientX - r.left) * SC, y = (e.clientY - r.top) * SC;
304 if (lx > -9000) { mvx = mvx * 0.5 + (x - lx) * 0.9; mvy = mvy * 0.5 + (y - ly) * 0.9; }
305 mx = x; my = y; lx = x; ly = y; hot = 1;
306 };
307 const onLeave = () => { lx = ly = -9999; };
308 resize();
309 if (rm) {
310 for (let k = 0; k < 340; k++) step(1, 0.03);
311 const onR = () => { resize(); for (let k = 0; k < 340; k++) step(1, 0.03); };
312 window.addEventListener("resize", onR);
313 return () => window.removeEventListener("resize", onR);
314 }
315 const io = typeof IntersectionObserver !== "undefined" ? new IntersectionObserver(([e]) => { visible = e.isIntersecting; }) : null;
316 io?.observe(hero);
317 hero.addEventListener("pointermove", onMove); hero.addEventListener("pointerleave", onLeave);
318 window.addEventListener("resize", resize);
319 raf = requestAnimationFrame(frame);
320 return () => { cancelAnimationFrame(raf); io?.disconnect(); hero.removeEventListener("pointermove", onMove); hero.removeEventListener("pointerleave", onLeave); window.removeEventListener("resize", resize); };
321 }, []);
322
323 /* ───── scroll choreography ───── */
324 useEffect(() => {
325 const rm = prefersReduced();
326 let raf = 0, cur = -1, solid = false;
327 const prog = (el: HTMLElement | null, vh: number) => { if (!el) return null; const r = el.getBoundingClientRect(); if (r.bottom < -80 || r.top > vh + 80) return null; return clamp(-r.top / Math.max(1, r.height - vh)); };
328 const WN = words.length;
329 const frame = () => {
330 raf = requestAnimationFrame(frame);
331 const vh = window.innerHeight, vw = window.innerWidth, sy = window.scrollY;
332 const sol = sy > 40; if (sol !== solid) { solid = sol; setNavSolid(sol); }
333 if (heroTxtRef.current) { const u = clamp(sy / (vh * 0.9)); heroTxtRef.current.style.transform = `translate3d(0,${(u * 140).toFixed(1)}px,0)`; heroTxtRef.current.style.opacity = String(1 - u * 0.9); }
334 const pm = prog(manRef.current, vh);
335 if (pm !== null) wordRefs.current.forEach((w, i) => { if (!w) return; const s = (i / WN) * 0.8 + 0.06; const u = clamp((pm - s) / 0.06); w.style.opacity = String(0.13 + 0.87 * u); w.style.transform = `translate3d(0,${((1 - u) * 14).toFixed(1)}px,0)`; });
336 const pb = prog(botRef.current, vh);
337 if (pb !== null) {
338 const x = -150 + pb * 640;
339 sweepRef.current?.setAttribute("transform", `translate(${x.toFixed(1)} 0) skewX(-16)`);
340 sweep2Ref.current?.setAttribute("transform", `translate(${(x - 135).toFixed(1)} 0) skewX(-16)`);
341 glowRef.current?.setAttribute("opacity", (0.2 + 0.55 * Math.sin(pb * Math.PI)).toFixed(3));
342 tiltRef.current?.setAttribute("transform", `rotate(${(-3 + pb * 6).toFixed(2)} 200 590)`);
343 [0.18, 0.5, 0.82].forEach((c, i) => { const el = capRefs.current[i]; if (el) { const o = clamp(1.7 - Math.abs(pb - c) / 0.1); el.style.opacity = String(o); el.style.transform = `translate3d(0,${((1 - o) * 24).toFixed(1)}px,0)`; } });
344 if (bgWordRef.current) bgWordRef.current.style.transform = `translate3d(${(-pb * 26).toFixed(2)}vw,0,0)`;
345 }
346 const pn = prog(notesRef.current, vh);
347 if (pn !== null && trackRef.current) {
348 const pos = pn * 2;
349 trackRef.current.style.transform = `translate3d(${(-pos * vw).toFixed(1)}px,0,0)`;
350 atmRefs.current.forEach((el, i) => { if (el) el.style.opacity = String(clamp(1.15 - Math.abs(pos - i) * 1.1)); });
351 panelRefs.current.forEach((el, i) => {
352 if (!el) return; const off = i - pos;
353 const word = el.querySelector<HTMLElement>(".nr-gw"), body = el.querySelector<HTMLElement>(".nr-pbody");
354 if (word) word.style.transform = `translate3d(${(off * vw * 0.32).toFixed(1)}px,0,0)`;
355 if (body) { body.style.transform = `translate3d(${(off * vw * 0.1).toFixed(1)}px,0,0)`; body.style.opacity = String(clamp(1.25 - Math.abs(off) * 1.3)); }
356 });
357 if (fillRef.current) fillRef.current.style.transform = `scaleX(${pn.toFixed(3)})`;
358 const n = Math.round(pos); if (n !== cur) { cur = n; setNote(n); }
359 }
360 const pq = prog(quizRef.current, vh);
361 if (pq !== null) qringRef.current?.setAttribute("transform", `rotate(${(pq * 140).toFixed(1)})`);
362 };
363 if (rm) {
364 wordRefs.current.forEach((w) => { if (w) w.style.opacity = "1"; });
365 return;
366 }
367 raf = requestAnimationFrame(frame);
368 return () => cancelAnimationFrame(raf);
369 }, [words.length]);
370
371 /* result animation trigger */
372 useEffect(() => {
373 if (step < QUIZ.length) { setGo(false); return; }
374 const id = window.setTimeout(() => setGo(true), 80);
375 return () => window.clearTimeout(id);
376 }, [step]);
377
378 const scent = (() => {
379 if (step < QUIZ.length) return SCENTS[0];
380 const tot = [0, 0, 0, 0]; picks.forEach((p) => p.forEach((v, i) => { tot[i] += v; }));
381 let b = 0; tot.forEach((v, i) => { if (v > tot[b]) b = i; });
382 return SCENTS[b];
383 })();
384 const choose = (w: number[]) => { setPicks((p) => [...p, w]); setStep((s) => s + 1); };
385 const back = () => { setPicks((p) => p.slice(0, -1)); setStep((s) => Math.max(0, s - 1)); };
386 const retake = () => { setPicks([]); setStep(0); setReserved(false); };
387
388 const goTo = (id: string) => (e: { preventDefault: () => void }) => {
389 e.preventDefault();
390 document.getElementById(id)?.scrollIntoView({ behavior: prefersReduced() ? "auto" : "smooth", block: "start" });
391 };
392
393 return (
394 <div ref={rootRef} className="nr" id="nr-top">
395 <style>{CSS}</style>
396 <Defs />
397 <div className="nr-curtain" aria-hidden="true" />
398
399 <header className={"nr-nav" + (navSolid ? " solid" : "")}>
400 <a href="#nr-top" className="nr-logo" onClick={goTo("nr-top")}>Vérane</a>
401 <nav className="nr-nav-links" aria-label="Primary">
402 <a href="#nr-flacon" onClick={goTo("nr-flacon")}>The flacon</a>
403 <a href="#nr-notes" onClick={goTo("nr-notes")}>Notes</a>
404 <a href="#nr-quiz" onClick={goTo("nr-quiz")}>Find your scent</a>
405 <a href="#nr-collection" onClick={goTo("nr-collection")}>Collection</a>
406 </nav>
407 <a href="#nr-collection" onClick={goTo("nr-collection")} className="nr-nav-bag">Boutique<span>0</span></a>
408 </header>
409
410 {/* ───────────── hero ───────────── */}
411 <section className="nr-hero" ref={heroRef} aria-label="Nox Ambre, Eau de Parfum">
412 <canvas ref={cvsRef} className="nr-smoke" aria-hidden="true" />
413 <div className="nr-vig" aria-hidden="true" />
414 <div className="nr-hero-in" ref={heroTxtRef}>
415 <div className="nr-kicker">Maison de parfum · Grasse — Paris · Nº 19</div>
416 <h1 aria-label="Smoke and Amber">
417 <span className="nr-ln" aria-hidden="true">{"Smoke".split("").map((c, i) => <span key={i} style={sv({ "--i": i })}>{c}</span>)}</span>
418 <span className="nr-ln amber2" aria-hidden="true">{"& Amber".split("").map((c, i) => <span key={i} style={sv({ "--i": i + 6 })}>{c === " " ? " " : c}</span>)}</span>
419 </h1>
420 </div>
421 <div className="nr-hero-foot">
422 <div className="nr-hero-meta">
423 <b>Nox Ambre</b>
424 <span>Eau de Parfum · 50 ml</span>
425 <a href="#nr-quiz" onClick={goTo("nr-quiz")}>Discover your scent →</a>
426 </div>
427 <div className="nr-hero-hint"><i />Move through the smoke</div>
428 </div>
429 </section>
430
431 {/* ───────────── manifesto ───────────── */}
432 <section className="nr-man" ref={manRef} aria-label="Manifesto">
433 <div className="nr-man-stick">
434 <div className="nr-label">Manifeste</div>
435 <p className="nr-man-p" aria-label={MANIFESTO}>
436 {words.map((w, i) => (
437 <span key={i} aria-hidden="true" ref={(el) => { wordRefs.current[i] = el; }} className={AMBER_WORDS.has(w.replace(/[.,—]/g, "")) ? "am" : ""}>{w}</span>
438 ))}
439 </p>
440 </div>
441 </section>
442
443 {/* ───────────── flacon ───────────── */}
444 <section className="nr-bot" id="nr-flacon" ref={botRef}>
445 <div className="nr-bot-stick">
446 <div className="nr-bgword" ref={bgWordRef} aria-hidden="true">Le Flacon</div>
447 <Flacon big className="nr-flacon" sweepRef={(el) => { sweepRef.current = el; }} sweep2Ref={(el) => { sweep2Ref.current = el; }} glowRef={(el) => { glowRef.current = el; }} tiltRef={(el) => { tiltRef.current = el; }} />
448 {[
449 ["01", "The glass", "Hand-blown in the Vallée de la Bresle. 480 grams of lead-free crystal, so thick it bends the light before it reaches the juice.", "l t"],
450 ["02", "The juice", "Thirty-two percent absolute, macerated twelve weeks on oak. Nothing added to make it louder. Only time.", "r m"],
451 ["03", "The cap", "Smoked oak, turned by one family in the Jura, finished with a ring of brushed amber brass.", "l b"],
452 ].map(([n, t, b, pos], i) => (
453 <div key={n} className={"nr-cap " + pos} ref={(el) => { capRefs.current[i] = el; }}>
454 <span>{n}</span><h3>{t}</h3><p>{b}</p>
455 </div>
456 ))}
457 </div>
458 </section>
459
460 {/* ───────────── notes ───────────── */}
461 <section className="nr-notes" id="nr-notes" ref={notesRef} aria-label="Notes: top, heart and base">
462 <div className="nr-notes-stick">
463 {NOTES.map((n, i) => (
464 <div key={n.k} className="nr-atm" ref={(el) => { atmRefs.current[i] = el; }} style={sv({ "--atm": n.atm })} aria-hidden="true" />
465 ))}
466 <div className="nr-track" ref={trackRef}>
467 {NOTES.map((n, i) => (
468 <div key={n.k} className="nr-panel" ref={(el) => { panelRefs.current[i] = el; }} style={sv({ "--atm": n.atm })}>
469 <div className="nr-gw" aria-hidden="true">{n.word}</div>
470 <div className="nr-pbody">
471 <div className="nr-pk"><b>{n.n}</b><span>{n.when}</span></div>
472 <h2>{n.k} <em>notes</em></h2>
473 <p className="nr-pline">{n.line}</p>
474 <ul>
475 {n.list.map(([a, d]) => (<li key={a}><span>{a}</span><small>{d}</small></li>))}
476 </ul>
477 </div>
478 <div className="nr-orb" aria-hidden="true"><i /><i /><i /></div>
479 </div>
480 ))}
481 </div>
482 <div className="nr-tl" aria-hidden="true">
483 <div className="nr-tl-bar"><span ref={fillRef} /></div>
484 <div className="nr-tl-labels">{NOTES.map((n, i) => <span key={n.k} className={i === note ? "on" : ""}>{n.k} · {n.when}</span>)}</div>
485 </div>
486 </div>
487 </section>
488
489 {/* ───────────── quiz ───────────── */}
490 <section className="nr-quiz" id="nr-quiz" ref={quizRef}>
491 <div className="nr-quiz-stick">
492 <svg className="nr-qring" viewBox="-400 -400 800 800" aria-hidden="true">
493 <g ref={qringRef}>
494 {[380, 300, 220].map((r, i) => <circle key={r} r={r} fill="none" stroke="#E9E2D3" strokeOpacity={0.1 - i * 0.02} strokeDasharray={i === 1 ? "2 10" : "none"} />)}
495 <circle cx="380" cy="0" r="5" fill="#E0A04A" /><circle cx="-220" cy="0" r="3" fill="#E9E2D3" />
496 </g>
497 </svg>
498 <div className="nr-quiz-in">
499 {step < QUIZ.length ? (
500 <div key={step} className="nr-qstep">
501 <div className="nr-label">Discover your scent · 0{step + 1} / 0{QUIZ.length}</div>
502 <h2 className="nr-qh">{QUIZ[step].q}</h2>
503 <div className="nr-opts">
504 {QUIZ[step].o.map(([t, w], i) => (
505 <button key={t} type="button" className="nr-opt" onClick={() => choose(w)}>
506 <small>{["I", "II", "III", "IV"][i]}</small><span>{t}</span>
507 </button>
508 ))}
509 </div>
510 <div className="nr-qnav">
511 <div className="dots" aria-hidden="true">{QUIZ.map((_, i) => <i key={i} className={i <= step ? "on" : ""} />)}</div>
512 {step > 0 && <button type="button" onClick={back} className="nr-linkbtn">← Back</button>}
513 </div>
514 </div>
515 ) : (
516 <div className={"nr-res" + (go ? " go" : "")} role="status" aria-live="polite">
517 <div className="nr-res-l">
518 <div className="nr-label">Your signature · Nº {scent.num}</div>
519 <h2 className="nr-res-name" aria-label={scent.name}>
520 {scent.name.split("").map((c, i) => <span key={i} aria-hidden="true" style={sv({ "--i": i })}>{c === " " ? " " : c}</span>)}
521 </h2>
522 <p className="nr-res-d">{scent.desc}</p>
523 <div className="nr-bars">
524 {["Warmth", "Sillage", "Longevity"].map((l, i) => (
525 <div key={l}><span>{l}</span><div><i style={sv({ "--w": scent.bars[i] + "%", "--k": i })} /></div><b>{scent.bars[i]}</b></div>
526 ))}
527 </div>
528 <div className="nr-res-notes">{["Top", "Heart", "Base"].map((l, i) => <div key={l}><small>{l}</small><span>{scent.notes[i]}</span></div>)}</div>
529 <div className="nr-res-cta">
530 <button type="button" className="nr-btn" onClick={() => setReserved(true)} disabled={reserved}>{reserved ? "Sample reserved ✓" : "Reserve a 2 ml sample"}</button>
531 <button type="button" className="nr-linkbtn" onClick={retake}>Retake the quiz</button>
532 </div>
533 </div>
534 <div className="nr-res-r" style={sv({ "--tint": scent.tint })}>
535 <div className="nr-res-halo" aria-hidden="true" />
536 <Flacon tint={scent.tint} level={go ? 1 : 0.02} className="nr-res-bottle" />
537 </div>
538 </div>
539 )}
540 </div>
541 </div>
542 </section>
543
544 {/* ───────────── collection ───────────── */}
545 <section className="nr-coll" id="nr-collection">
546 <div className="nr-wrap">
547 <div className="nr-label" data-rv>The collection</div>
548 <h2 className="nr-h2" data-rv>Four ways to <em>disappear.</em></h2>
549 <ul className="nr-rows">
550 {COLLECTION.map((c, i) => (
551 <li key={c.n} data-rv style={sv({ "--d": i * 0.08 + "s", "--tint": c.tint })}>
552 <a href="#" onClick={NOOP} className="nr-row">
553 <span className="no">{c.no}</span>
554 <span className="nm">{c.n}</span>
555 <span className="nt">{c.notes}</span>
556 <span className="pr">{c.price}<small>50 ml</small></span>
557 <span className="bt" aria-hidden="true"><Flacon tint={c.tint} /></span>
558 </a>
559 </li>
560 ))}
561 </ul>
562 </div>
563 </section>
564
565 {/* ───────────── footer ───────────── */}
566 <footer className="nr-foot">
567 <div className="nr-wrap nr-foot-grid">
568 <div data-rv>
569 <div className="nr-label">The letter</div>
570 <p className="nr-foot-p">One letter a season. New accords, rare restocks, nothing else.</p>
571 <form className="nr-sub" onSubmit={(e: FormEvent) => { e.preventDefault(); if (email.includes("@")) setJoined(true); }}>
572 {joined ? <div className="nr-thanks">Merci. The next letter finds you in spring.</div> : (
573 <>
574 <label className="nr-sr" htmlFor="nr-email">Email</label>
575 <input id="nr-email" type="email" required placeholder="your@email.com" value={email} onChange={(e) => setEmail(e.target.value)} />
576 <button type="submit">Subscribe</button>
577 </>
578 )}
579 </form>
580 </div>
581 <div data-rv style={sv({ "--d": ".1s" })}>
582 <div className="nr-label">Maison</div>
583 <address>14 rue du Faubourg Saint-Honoré<br />75008 Paris<br />Tue – Sat, 11 – 19h</address>
584 </div>
585 <div data-rv style={sv({ "--d": ".2s" })}>
586 <div className="nr-label">House</div>
587 <ul className="nr-foot-links">{["Our story", "The atelier in Grasse", "Refills", "Stockists", "Contact"].map((l) => <li key={l}><a href="#" onClick={NOOP}>{l}</a></li>)}</ul>
588 </div>
589 </div>
590 <div className="nr-giant" aria-hidden="true">Vérane</div>
591 <div className="nr-wrap nr-legal"><span>© 2026 Maison Vérane SAS</span><span>Privacy · Terms · Shipping</span></div>
592 </footer>
593 </div>
594 );
595}
596
597export default Page;
598
599/* ------------------------------------------------------------------ */
600/* Styles */
601/* ------------------------------------------------------------------ */
602const CSS = `
603.nr{--nr-black:#070605;--nr-ink:#0E0C0A;--nr-bone:#E9E2D3;--nr-mute:#A8A196;--nr-amber:#E0A04A;--nr-line:rgba(233,226,211,.14);
604--nr-serif:"Cormorant Garamond","Playfair Display","Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
605--nr-sans:"Helvetica Neue","Inter",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Arial,sans-serif;
606--nr-out:cubic-bezier(.22,1,.36,1);
607position:relative;box-sizing:border-box;width:100%;background:var(--nr-black);color:var(--nr-bone);font-family:var(--nr-sans);font-size:16px;line-height:1.6;font-weight:300;text-align:left;overflow-x:clip;-webkit-font-smoothing:antialiased}
608.nr *,.nr *::before,.nr *::after{box-sizing:border-box}
609:where(.nr) :where(h1,h2,h3,p,ul,li,form,address){margin:0;padding:0;font:inherit;letter-spacing:inherit;font-style:normal}
610:where(.nr) :where(ul){list-style:none}
611:where(.nr) :where(a){color:inherit;text-decoration:none}
612:where(.nr) :where(button,input){font:inherit;color:inherit;margin:0;border:0;background:none;padding:0;appearance:none;-webkit-appearance:none;border-radius:0;text-align:inherit}
613:where(.nr) :where(button){cursor:pointer}
614.nr :focus-visible{outline:1px solid var(--nr-amber);outline-offset:4px}
615:where(.nr) :where(svg){display:block;max-width:100%}
616.nr .nr-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
617.nr-wrap{width:100%;max-width:1280px;margin:0 auto;padding:0 48px}
618.nr-label{font-family:var(--nr-sans);font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--nr-amber);font-weight:400}
619.nr-h2{margin-top:22px;font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:clamp(3rem,9vw,8.4rem);line-height:.92;letter-spacing:-.025em}
620.nr-h2 em{color:var(--nr-amber)}
621.nr-btn{display:inline-flex;align-items:center;justify-content:center;height:54px;padding:0 34px;border:1px solid var(--nr-amber);color:var(--nr-black);background:var(--nr-amber);font-size:12px;letter-spacing:.26em;text-transform:uppercase;font-weight:500;transition:background .4s,color .4s,transform .5s var(--nr-out)}
622.nr-btn:hover:not(:disabled){background:transparent;color:var(--nr-amber)}
623.nr-btn:disabled{background:transparent;color:var(--nr-amber);cursor:default}
624.nr-linkbtn{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--nr-mute);border-bottom:1px solid transparent;transition:color .3s,border-color .3s;padding:4px 0}
625.nr-linkbtn:hover{color:var(--nr-bone);border-color:var(--nr-bone)}
626
627.nr-curtain{position:fixed;inset:0;z-index:100;background:var(--nr-black);pointer-events:none;animation:nrCurtain 1.8s .15s var(--nr-out) forwards}
628@keyframes nrCurtain{to{opacity:0;visibility:hidden}}
629
630/* nav */
631.nr-nav{position:fixed;z-index:60;top:0;left:0;right:0;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:22px 48px;transition:background .5s,padding .5s;mix-blend-mode:normal}
632.nr-nav.solid{background:rgba(7,6,5,.78);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);padding-top:14px;padding-bottom:14px;border-bottom:1px solid var(--nr-line)}
633.nr-logo{font-family:var(--nr-serif);font-size:26px;letter-spacing:.34em;text-transform:uppercase;font-weight:500}
634.nr-nav-links{display:flex;gap:38px;font-size:11px;letter-spacing:.26em;text-transform:uppercase}
635.nr-nav-links a,.nr-nav-bag{opacity:.78;transition:opacity .3s,color .3s}.nr-nav-links a:hover,.nr-nav-bag:hover{opacity:1;color:var(--nr-amber)}
636.nr-nav-bag{justify-self:end;font-size:11px;letter-spacing:.26em;text-transform:uppercase;display:inline-flex;gap:10px;align-items:center}
637.nr-nav-bag span{width:22px;height:22px;border:1px solid var(--nr-line);border-radius:50%;display:grid;place-items:center;font-size:10px;letter-spacing:0}
638
639/* hero */
640.nr-hero{position:relative;height:100vh;min-height:620px;overflow:hidden;background:var(--nr-black);cursor:crosshair}
641.nr-smoke{position:absolute;inset:0;width:100%;height:100%}
642.nr-vig{position:absolute;inset:0;pointer-events:none;background:radial-gradient(70% 45% at 50% 105%,rgba(224,160,74,.16),transparent 70%),radial-gradient(75% 70% at 50% 50%,transparent 35%,rgba(7,6,5,.8) 100%),linear-gradient(180deg,rgba(7,6,5,.55),transparent 22%,transparent 70%,rgba(7,6,5,.8))}
643.nr-hero-in{position:absolute;left:0;right:0;bottom:15vh;padding:0 48px;pointer-events:none;will-change:transform,opacity}
644.nr-kicker{font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--nr-mute);margin-bottom:2vh;opacity:0;animation:nrFade 1.4s 1.6s var(--nr-out) forwards}
645.nr-hero h1{font-family:var(--nr-serif);font-style:italic;font-weight:300;font-size:clamp(5.2rem,21vw,21rem);line-height:.8;letter-spacing:-.04em;color:var(--nr-bone)}
646.nr-ln{display:block;overflow:hidden;padding:.06em .1em .12em 0;margin-right:-.1em}
647.nr-ln span{display:inline-block;transform:translate3d(0,112%,0);animation:nrRise 1.5s var(--nr-out) calc(var(--i) * 70ms + 1s) forwards}
648.nr-ln.amber2{color:var(--nr-amber);padding-left:.3em}
649@keyframes nrRise{to{transform:none}}
650@keyframes nrFade{to{opacity:1}}
651.nr-hero-foot{position:absolute;left:0;right:0;bottom:0;display:flex;justify-content:space-between;align-items:flex-end;padding:0 48px 34px;gap:20px;opacity:0;animation:nrFade 1.4s 2.1s var(--nr-out) forwards;pointer-events:none}
652.nr-hero-meta{display:grid;gap:4px;font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:var(--nr-mute);pointer-events:auto}
653.nr-hero-meta b{font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:28px;letter-spacing:0;text-transform:none;color:var(--nr-bone)}
654.nr-hero-meta a{margin-top:8px;color:var(--nr-amber);transition:letter-spacing .4s var(--nr-out)}.nr-hero-meta a:hover{letter-spacing:.3em}
655.nr-hero-hint{display:flex;align-items:center;gap:14px;font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:var(--nr-mute)}
656.nr-hero-hint i{width:46px;height:1px;background:var(--nr-amber);transform-origin:left;animation:nrLine 2.6s ease-in-out infinite}
657@keyframes nrLine{50%{transform:scaleX(.2)}}
658
659.nr.nr-js [data-rv]{opacity:0;transform:translate3d(0,40px,0);transition:opacity 1.2s var(--nr-out) var(--d,0s),transform 1.2s var(--nr-out) var(--d,0s)}
660.nr.nr-js [data-rv].in{opacity:1;transform:none}
661
662/* manifesto */
663.nr-man{position:relative;height:300vh;background:var(--nr-black)}
664.nr-man-stick{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;justify-content:center;padding:0 48px;max-width:1380px;margin:0 auto}
665.nr-man-p{margin-top:28px;font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:clamp(2.1rem,6.4vw,6.4rem);line-height:1.02;letter-spacing:-.025em}
666.nr-man-p span{display:inline-block;margin-right:.26em;opacity:.13;will-change:opacity,transform}
667.nr-man-p span.am{color:var(--nr-amber)}
668
669/* flacon */
670.nr-bot{position:relative;height:360vh;background:radial-gradient(60% 50% at 50% 55%,#15110c,var(--nr-black) 70%)}
671.nr-bot-stick{position:sticky;top:0;height:100vh;overflow:hidden;display:grid;place-items:center}
672.nr-bgword{position:absolute;left:0;top:50%;margin-top:-.5em;white-space:nowrap;font-family:var(--nr-serif);font-style:italic;font-size:clamp(10rem,36vw,36rem);line-height:1;color:transparent;-webkit-text-stroke:1px rgba(233,226,211,.13);padding-left:12vw;will-change:transform}
673.nr-flacon{position:relative;height:min(82vh,760px);width:auto;aspect-ratio:400/640;font-family:var(--nr-serif)}
674.nr-cap{position:absolute;max-width:320px;opacity:0;will-change:opacity,transform}
675.nr-cap.l{left:max(48px,6vw)}.nr-cap.r{right:max(48px,6vw)}
676.nr-cap.t{top:20vh}.nr-cap.m{top:42vh}.nr-cap.b{bottom:15vh}
677.nr-cap span{font-size:11px;letter-spacing:.34em;color:var(--nr-amber)}
678.nr-cap h3{margin-top:10px;font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:clamp(2rem,3.4vw,3.2rem);line-height:1}
679.nr-cap p{margin-top:14px;color:var(--nr-mute);font-size:15px;line-height:1.65}
680.nr-cap.r{text-align:right}
681
682/* notes */
683.nr-notes{position:relative;height:420vh;background:var(--nr-black)}
684.nr-notes-stick{position:sticky;top:0;height:100vh;overflow:hidden}
685.nr-atm{position:absolute;inset:0;opacity:0;background:radial-gradient(70% 80% at 72% 55%,color-mix(in srgb,var(--atm) 30%,transparent),transparent 70%),radial-gradient(60% 60% at 10% 100%,color-mix(in srgb,var(--atm) 16%,transparent),transparent 70%)}
686.nr-atm:first-child{opacity:1}
687.nr-track{position:absolute;left:0;top:0;height:100%;width:300vw;display:flex;will-change:transform}
688.nr-panel{position:relative;flex:0 0 100vw;height:100%;overflow:hidden;display:flex;align-items:center;padding:0 7vw}
689.nr-gw{position:absolute;left:0;bottom:-.12em;font-family:var(--nr-serif);font-style:italic;font-weight:300;font-size:clamp(11rem,38vw,40rem);line-height:.8;letter-spacing:-.05em;color:color-mix(in srgb,var(--atm) 20%,transparent);white-space:nowrap;padding-left:3vw;will-change:transform}
690.nr-pbody{position:relative;z-index:2;max-width:640px;will-change:transform,opacity}
691.nr-pk{display:flex;align-items:center;gap:18px;font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:var(--atm)}
692.nr-pk b{font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:28px;letter-spacing:0}
693.nr-panel h2{margin-top:14px;font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:clamp(3.4rem,9vw,8rem);line-height:.9;letter-spacing:-.03em}
694.nr-panel h2 em{color:var(--atm)}
695.nr-pline{margin-top:20px;max-width:28em;font-family:var(--nr-serif);font-style:italic;font-size:clamp(1.2rem,2vw,1.7rem);line-height:1.3;color:var(--nr-bone);opacity:.85}
696.nr-panel ul{margin-top:32px;border-top:1px solid var(--nr-line)}
697.nr-panel li{display:flex;justify-content:space-between;align-items:baseline;gap:20px;padding:15px 0;border-bottom:1px solid var(--nr-line)}
698.nr-panel li span{font-family:var(--nr-serif);font-size:clamp(1.3rem,2vw,1.75rem);color:var(--nr-bone)}
699.nr-panel li small{font-size:12px;letter-spacing:.06em;color:var(--nr-mute);text-align:right}
700.nr-orb{position:absolute;right:8vw;top:50%;width:min(36vw,520px);aspect-ratio:1;margin-top:calc(min(36vw,520px) / -2);pointer-events:none}
701.nr-orb i{position:absolute;border-radius:50%;filter:blur(36px);background:radial-gradient(circle,color-mix(in srgb,var(--atm) 55%,transparent),transparent 70%);animation:nrDrift 14s ease-in-out infinite}
702.nr-orb i:nth-child(1){inset:8%}.nr-orb i:nth-child(2){inset:30% 0 0 34%;animation-delay:-5s;animation-duration:18s}.nr-orb i:nth-child(3){inset:0 38% 42% 0;animation-delay:-9s;opacity:.6}
703@keyframes nrDrift{50%{transform:translate3d(8%,-6%,0) scale(1.15)}}
704.nr-tl{position:absolute;left:7vw;right:7vw;bottom:34px;z-index:3}
705.nr-tl-bar{height:1px;background:var(--nr-line)}
706.nr-tl-bar span{display:block;height:1px;background:var(--nr-amber);transform:scaleX(0);transform-origin:left}
707.nr-tl-labels{display:flex;justify-content:space-between;margin-top:14px;font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#6f6a62}
708.nr-tl-labels .on{color:var(--nr-amber)}
709
710/* quiz */
711.nr-quiz{position:relative;height:190vh;background:var(--nr-black);border-top:1px solid var(--nr-line)}
712.nr-quiz-stick{position:sticky;top:0;height:100vh;overflow:hidden;display:flex;align-items:center}
713.nr-qring{position:absolute;left:50%;top:50%;width:max(130vh,900px);transform:translate(-50%,-50%);pointer-events:none}
714.nr-quiz-in{position:relative;width:100%;max-width:1280px;margin:0 auto;padding:0 48px}
715.nr-qstep{animation:nrStep .9s var(--nr-out)}
716@keyframes nrStep{from{opacity:0;transform:translate3d(0,24px,0)}}
717.nr-qh{margin-top:24px;max-width:11em;font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:clamp(2.8rem,7vw,6.6rem);line-height:.96;letter-spacing:-.03em}
718.nr-opts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 48px;margin-top:44px;max-width:980px}
719.nr-opt{display:flex;align-items:baseline;gap:20px;padding:20px 0;border-top:1px solid var(--nr-line);font-family:var(--nr-serif);font-size:clamp(1.3rem,2.2vw,2rem);line-height:1.15;transition:padding .5s var(--nr-out),color .3s,border-color .3s}
720.nr-opt small{font-family:var(--nr-sans);font-size:11px;letter-spacing:.2em;color:var(--nr-amber);min-width:2.2em}
721.nr-opt:hover,.nr-opt:focus-visible{padding-left:18px;color:var(--nr-amber);border-color:var(--nr-amber)}
722.nr-qnav{display:flex;align-items:center;gap:30px;margin-top:34px}
723.nr-qnav .dots{display:flex;gap:8px}.nr-qnav .dots i{width:26px;height:1px;background:var(--nr-line)}.nr-qnav .dots i.on{background:var(--nr-amber)}
724.nr-res{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,.75fr);gap:5vw;align-items:center}
725.nr-res-name{margin-top:14px;font-family:var(--nr-serif);font-style:italic;font-weight:400;font-size:clamp(3.4rem,8vw,7.6rem);line-height:.9;letter-spacing:-.03em;color:var(--nr-bone)}
726.nr-res-name span{display:inline-block;opacity:0;transform:translate3d(0,40%,0) rotate(4deg);transition:opacity 1s var(--nr-out) calc(var(--i) * 55ms),transform 1.2s var(--nr-out) calc(var(--i) * 55ms)}
727.nr-res.go .nr-res-name span{opacity:1;transform:none}
728.nr-res-d{margin-top:18px;max-width:34em;color:var(--nr-mute);font-size:15.5px;opacity:0;transition:opacity 1s .5s}
729.nr-res.go .nr-res-d,.nr-res.go .nr-res-notes,.nr-res.go .nr-res-cta{opacity:1}
730.nr-bars{margin-top:24px;display:grid;gap:10px;max-width:440px}
731.nr-bars>div{display:grid;grid-template-columns:90px 1fr 28px;align-items:center;gap:14px;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--nr-mute)}
732.nr-bars>div>div{height:1px;background:var(--nr-line);position:relative}
733.nr-bars i{position:absolute;left:0;top:-1px;height:3px;width:var(--w);background:var(--nr-amber);transform:scaleX(0);transform-origin:left;transition:transform 1.8s var(--nr-out) calc(.5s + var(--k) * .15s)}
734.nr-res.go .nr-bars i{transform:scaleX(1)}
735.nr-bars b{font-weight:400;color:var(--nr-bone);letter-spacing:0;text-align:right}
736.nr-res-notes{margin-top:22px;display:grid;grid-template-columns:repeat(3,1fr);gap:18px;max-width:640px;opacity:0;transition:opacity 1s .9s}
737.nr-res-notes small{display:block;font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--nr-amber);margin-bottom:4px}
738.nr-res-notes span{font-family:var(--nr-serif);font-style:italic;font-size:1.1rem;line-height:1.2;color:var(--nr-bone)}
739.nr-res-cta{margin-top:26px;display:flex;align-items:center;gap:28px;flex-wrap:wrap;opacity:0;transition:opacity 1s 1.1s}
740.nr-res-r{position:relative;justify-self:center;width:100%;display:grid;place-items:center}
741.nr-res-halo{position:absolute;width:120%;aspect-ratio:1;border-radius:50%;background:radial-gradient(circle,color-mix(in srgb,var(--tint) 40%,transparent),transparent 65%);filter:blur(10px)}
742.nr-res-bottle{position:relative;height:min(56vh,520px);width:auto;aspect-ratio:400/640}
743.nr .nr-liq{transition:transform 2.4s var(--nr-out)}
744
745/* collection */
746.nr-coll{padding:170px 0 120px;background:var(--nr-black);border-top:1px solid var(--nr-line)}
747.nr-rows{margin-top:70px;border-top:1px solid var(--nr-line)}
748.nr-row{position:relative;display:grid;grid-template-columns:80px minmax(0,1.3fr) minmax(0,1.2fr) 150px;align-items:center;gap:24px;padding:34px 0;border-bottom:1px solid var(--nr-line);transition:padding .6s var(--nr-out)}
749.nr-row .no{font-size:11px;letter-spacing:.3em;color:var(--nr-amber)}
750.nr-row .nm{font-family:var(--nr-serif);font-style:italic;font-size:clamp(2.4rem,6.4vw,6rem);line-height:.95;letter-spacing:-.03em;transition:color .4s,transform .7s var(--nr-out)}
751.nr-row .nt{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--nr-mute)}
752.nr-row .pr{text-align:right;font-family:var(--nr-serif);font-size:1.7rem}
753.nr-row .pr small{display:block;font-family:var(--nr-sans);font-size:10px;letter-spacing:.24em;color:var(--nr-mute);text-transform:uppercase}
754.nr-row .bt{position:absolute;right:190px;top:50%;width:62px;margin-top:-52px;opacity:0;transform:translate3d(0,24px,0) rotate(6deg);transition:opacity .5s,transform .8s var(--nr-out);pointer-events:none}
755.nr-row:hover .nm,.nr-row:focus-visible .nm{color:var(--tint);transform:translate3d(18px,0,0)}
756.nr-row:hover .bt,.nr-row:focus-visible .bt{opacity:1;transform:none}
757
758/* footer */
759.nr-foot{position:relative;padding:110px 0 0;background:var(--nr-black);border-top:1px solid var(--nr-line);overflow:hidden}
760.nr-foot-grid{display:grid;grid-template-columns:1.5fr 1fr 1fr;gap:60px}
761.nr-foot-p{margin-top:16px;font-family:var(--nr-serif);font-style:italic;font-size:1.7rem;line-height:1.2;max-width:15em}
762.nr-sub{display:flex;margin-top:26px;max-width:440px;border-bottom:1px solid var(--nr-bone)}
763.nr-sub input{flex:1;min-width:0;height:48px;font-size:15px}.nr-sub input::placeholder{color:#6f6a62}
764.nr-sub button{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--nr-amber);padding-left:16px}
765.nr-thanks{height:48px;display:flex;align-items:center;color:var(--nr-amber);font-family:var(--nr-serif);font-style:italic;font-size:1.2rem}
766.nr-foot address{margin-top:16px;color:var(--nr-mute);line-height:1.9}
767.nr-foot-links{margin-top:16px;display:grid;gap:6px}.nr-foot-links a{color:var(--nr-mute);transition:color .3s}.nr-foot-links a:hover{color:var(--nr-amber)}
768.nr-giant{margin-top:70px;text-align:center;font-family:var(--nr-serif);font-style:italic;font-weight:300;font-size:clamp(7rem,30vw,32rem);line-height:.74;letter-spacing:-.05em;white-space:nowrap;color:transparent;-webkit-text-stroke:1px rgba(233,226,211,.22);background:linear-gradient(180deg,rgba(224,160,74,.5),transparent 70%);-webkit-background-clip:text;background-clip:text;padding-bottom:.02em;height:.62em;overflow:hidden}
769.nr-legal{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;padding-top:22px;padding-bottom:30px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:#6f6a62;border-top:1px solid var(--nr-line);position:relative;background:var(--nr-black)}
770
771/* responsive */
772@media (max-width:900px){
773 .nr-wrap,.nr-nav,.nr-hero-in,.nr-hero-foot,.nr-man-stick,.nr-quiz-in{padding-left:22px;padding-right:22px}
774 .nr-nav{grid-template-columns:1fr auto;padding-top:16px;padding-bottom:16px}.nr-nav-links{display:none}.nr-logo{font-size:21px}
775 .nr-hero-hint{display:none}.nr-hero-in{bottom:19vh}
776 .nr-cap{max-width:none;left:22px!important;right:22px!important;text-align:left!important}
777 .nr-cap.t,.nr-cap.m,.nr-cap.b{top:auto;bottom:7vh}
778 .nr-cap p{font-size:14px}
779 .nr-flacon{height:min(56vh,520px);margin-top:-10vh}
780 .nr-panel{padding:0 22px;align-items:flex-start;padding-top:15vh}
781 .nr-orb{opacity:.7;right:-20vw;width:70vw;margin-top:-10vh}
782 .nr-panel ul{margin-top:20px}.nr-panel li{padding:11px 0;flex-direction:column;gap:2px}.nr-panel li small{text-align:left}
783 .nr-pline{margin-top:12px}
784 .nr-tl{left:22px;right:22px;bottom:20px}.nr-tl-labels{font-size:9px;letter-spacing:.12em}.nr-tl-labels span:not(.on){opacity:.5}
785 .nr-quiz{height:auto}.nr-quiz-stick{position:static;height:auto;min-height:100svh;padding:90px 0 70px}
786 .nr-opts{grid-template-columns:1fr;margin-top:26px}.nr-opt{padding:15px 0}
787 .nr-res{grid-template-columns:1fr;gap:30px}.nr-res-bottle{height:240px}.nr-res-r{order:-1}
788 .nr-res-notes{grid-template-columns:1fr 1fr 1fr;gap:10px}.nr-res-notes span{font-size:.95rem}
789 .nr-coll{padding:110px 0 80px}
790 .nr-row{grid-template-columns:1fr auto;gap:6px 14px;padding:26px 0}.nr-row .no{grid-column:1/-1}.nr-row .nt{grid-column:1;grid-row:3}.nr-row .nm{grid-column:1;grid-row:2}.nr-row .pr{grid-column:2;grid-row:2/4;align-self:end}
791 .nr-row .bt{right:84px;top:34px;margin:0;width:38px;opacity:.9;transform:none}
792 .nr-foot-grid{grid-template-columns:1fr;gap:36px}
793}
794@media (min-width:901px) and (max-height:700px){
795 .nr-quiz{height:auto}.nr-quiz-stick{position:static;height:auto;min-height:100vh;padding:80px 0}
796}
797
798@media (prefers-reduced-motion:reduce){
799 .nr *{animation:none!important}
800 .nr-curtain{display:none}
801 .nr-ln span,.nr-kicker,.nr-hero-foot{transform:none!important;opacity:1!important}
802 .nr.nr-js [data-rv]{opacity:1;transform:none;transition:none}
803 .nr-man{height:auto;padding:120px 0}.nr-man-stick{position:static;height:auto}.nr-man-p span{opacity:1!important;transform:none!important}
804 .nr-bot{height:auto;padding:80px 0 100px}.nr-bot-stick{position:static;height:auto;display:block;overflow:visible;padding:0 48px}
805 .nr-flacon{display:block;margin:0 auto 50px}
806 .nr-cap{position:static;opacity:1!important;transform:none!important;max-width:560px;margin:0 auto 34px;text-align:left!important}
807 .nr-bgword{display:none}
808 .nr-notes{height:auto}.nr-notes-stick{position:static;height:auto;overflow:visible}
809 .nr-atm{display:none}.nr-track{position:static;width:100%;flex-direction:column;transform:none!important}
810 .nr-panel{flex:none;height:auto;min-height:100vh;padding-top:12vh;padding-bottom:12vh;background:radial-gradient(70% 70% at 72% 50%,color-mix(in srgb,var(--atm) 24%,transparent),transparent 70%)}
811 .nr-gw,.nr-pbody{transform:none!important;opacity:1!important}.nr-tl{display:none}
812 .nr-quiz{height:auto}.nr-quiz-stick{position:static;height:auto;min-height:100vh;padding:100px 0}
813 .nr-res-name span{opacity:1;transform:none;transition:none}.nr-bars i{transform:scaleX(1);transition:none}
814 .nr-res-d,.nr-res-notes,.nr-res-cta{opacity:1;transition:none}.nr .nr-liq{transition:none}
815}
816`;

The design brief. For you and your AI agent.

Tokens, type, layout, components and motion — written from the real values in the file, so a model can extend the page without drifting.

Noir — DESIGN.md

Luxury perfume house landing page (the fictional Maison Vérane). Cinematic black, bone and exactly one accent, amber. Giant italic serif display, tiny tracked-out sans labels, long pauses. Imagery: a canvas smoke simulation, CSS-blurred colour atmospheres, one pre-rendered hero flacon photograph and small SVG flacons. No web fonts, no network.

All CSS lives in Page.tsx under the .nr prefix; every element is reset explicitly so it looks identical with or without a CSS reset.

1. Palette

RoleTokenHex
Page background (black)--nr-black#070605
Raised black--nr-ink#0E0C0A
Text, bone--nr-bone#E9E2D3
Muted text--nr-mute#A8A196
The single accent, amber--nr-amber#E0A04A
Hairlines--nr-linergba(233,226,211,.14)
Atmosphere: top notes (smoked sage)--atm#9DB5A6
Atmosphere: heart notes (wine rose)--atm#B04A5C
Atmosphere: base notes (amber)--atm#E0A04A
Quiz result tints--tintamber #E0A04A, rose #B04A5C, sage #9DB5A6, lilac-grey #C9C2D6

Atmospheres and quiz tints appear only as blurred glows and liquid colour, never as UI colour. Contrast: bone on black about 15:1, muted on black about 8:1, amber on black about 9:1.

2. Typography

  • Serif (--nr-serif): "Cormorant Garamond", "Playfair Display", "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif. Always italic for display, weight 300–400, tracking −0.025em to −0.05em.
  • Sans (--nr-sans): "Helvetica Neue", "Inter", ui-sans-serif, system-ui, Arial. Weight 300 body; labels 11px / 0.34em uppercase in amber.
StyleSpec
Hero titleserif italic 300, clamp(5.2rem, 21vw, 21rem) / 0.8; second line in amber, indented 0.3em
Section H2serif italic 400, clamp(3rem, 9vw, 8.4rem) / 0.92
Manifestoserif italic 400, clamp(2.1rem, 6.4vw, 6.4rem) / 1.02
Notes panel titleserif italic, clamp(3.4rem, 9vw, 8rem); giant outlined/tinted word up to 40rem behind
Quiz questionserif italic, clamp(2.8rem, 7vw, 6.6rem) / 0.96
Collection namesserif italic, clamp(2.4rem, 6.4vw, 6rem)
Labelssans 11px, 0.34em, uppercase
Buttonssans 12px, 0.26em uppercase, square corners, 54px tall

3. Spacing and shape

  • Container 1280px, side padding 48px (22px at ≤900px). Sections are 190–420vh when pinned; static sections 110–170px padding.
  • Zero radii anywhere except the round bag counter. Hairline dividers instead of cards.

4. Motion tokens

--nr-out: cubic-bezier(.22, 1, .36, 1) for everything. One rAF loop reads scrollY and getBoundingClientRect() and writes transforms and opacity (plus SVG transform attributes for the light sweep). A separate rAF loop paints the smoke, paused when the hero is off-screen.

5. Sections and choreography

  1. Hero. The curtain fades from black (1.8s). The smoke canvas runs at half resolution (soft upscale): ~1k particles (scaled to area) follow a two-octave value-noise flow field that drifts over time with an upward bias, drawn as additive hairline strokes over a faint black fade so threads trail. 84% bone, 16% amber. The pointer injects velocity (the field is dragged along the stroke), a tangential swirl inside a 240px radius, and respawns amber particles at the cursor. The title rises letter by letter out of overflow masks (1.5s, 70ms stagger); the block drifts down and fades on scroll.
  2. Manifesto (300vh pinned). Each word fades from 13% to 100% and rises 14px in sequence; "remembered", "warm" and "hour" are amber.
  3. Flacon (360vh pinned). A hand-built SVG flacon (glass body, liquid, collar, faceted cap, label). Two skewed gradient bands, clipped to the bottle silhouette and screen-blended, travel left to right with scroll; the amber glow behind rises and falls, the bottle tilts −3° → +3°, an outlined "Le Flacon" background word slides sideways, and three captions (glass, juice, cap) arrive in turn.
  4. Notes (420vh pinned, horizontal). A 300vw track translates with scroll. Each panel has a giant italic word that counter-parallaxes at 0.32 × viewport, a body block at 0.1, and three drifting blurred orbs. Three full-screen colour atmospheres crossfade behind. A hairline timeline fills along the bottom, labels light as the active note changes.
  5. Quiz (190vh pinned on desktop). Three questions, four options each, scored against four fragrances. A rotating ring field behind turns with scroll. The result animates: name letters rise with a stagger, three bars fill, notes and CTA fade in, and a flacon in the matched colour fills from empty (2.4s) under a tinted halo. Sample-reserve button, retake link.
  6. Collection. Four huge rows; on hover the name slides right and takes the fragrance colour while a small flacon lifts in (always visible on mobile).
  7. Footer. Newsletter (inline success state), address, links, and a giant outlined "Vérane" wordmark fading to amber at its base.

6. Responsive and accessibility

  • ≤900px: nav links hide, captions dock to the bottom of the flacon stage, notes panels stack their content top-aligned, the quiz and result stop pinning and stack (bottle first), collection rows become two-line.
  • Quiz options are real buttons; the result is an aria-live region; decorative canvases and big words are aria-hidden.
  • prefers-reduced-motion: the smoke is a single pre-rendered frame, no curtain, no pinning, all manifesto words visible, captions stacked under the bottle, the three note panels stack vertically with their atmospheres, and the quiz result shows complete.

Assets

The hero flacon is public/img/lux/perfume-noir.webp (pre-rendered, black lacquer cap, amber glass; -800 variant included), placed inside the SVG with a radial mask so it melts into the black page while the scroll-driven light sweep runs on top. The small collection/result flacons stay SVG so they can be tinted per scent. Placeholder portraits, if needed, live in public/img/people/p1…p9.webp.

Questions about Noir

A perfume house in black, bone and a single amber — smoke you can stir, a flacon the light walks across. It is a free luxury fragrance landing page for React with a matching DESIGN.md brief.

Open the page, click Copy full source or Download .tsx, and drop the single Page file into your React project. Copy the public/img folder next to it for the demo photography, or swap in your own images.

Yes. It is MIT-licensed: free for personal and commercial projects, no signup and no attribution required.

Yes. The DESIGN.md describes tokens, type scale, layout, components and motion so Claude Code, Cursor, Lovable or v0 can extend the page without drifting from its look.