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.

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

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. All imagery is procedural: a canvas smoke simulation, an SVG flacon, CSS-blurred colour atmospheres. No images, 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.