Luxury Product Hero Section
A drop-in luxury hero: masked serif headline lines, a CSS-drawn glass flacon revealed by clip-path and floating on a 6s sine loop, a rotating textPath badge, a magnetic CTA, and swatches that re-tint the whole scene by tweening one CSS variable.
- Section Templates
- gsap
- splittext
- svg
- advanced
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1935 words of exact values — targets, eases, durations, triggers.
### EFFECT: Luxury Product Hero Section
**What it does (visual):** A full-viewport, two-column hero on a dark stage. Left column: a mono eyebrow ("MAISON VELLUM — EAU DE PARFUM · NO. 07"), a two-line light serif headline ("Night-blooming / jasmine, in glass"), a short muted paragraph, a row with the price ("€240 / 100 ml"), a filled pill CTA ("Add to cart") and a text link ("Read the composition"), and three edition swatches (Ambre, Vétiver, Rouge). Right column: a tall glass flacon drawn entirely in CSS — brushed-metal cap, short glass neck, a rounded body filled with tinted liquid, a cream paper label reading "Vellum / No. 07 · Ambre", a vertical white highlight down the left and a soft tinted glow on the floor behind it. A small circular badge with the mono text "LIMITED EDITION · 2026" on a ring sits at the lower-left of the bottle and rotates slowly forever (one turn per 20s). A "SCROLL" cue with a 1px lime line pulses at the bottom centre. On load: the bottle is revealed top-to-bottom by a clip-path wipe while settling from 110% to 100% (1.4s, `expo.inOut`); 0.3s in, the eyebrow rises; at 0.4s the two headline lines rise out of their masks 0.1s apart (1.2s, `power4.out`); at 0.9s the paragraph fades and rises; at 1.1s price, CTA and link rise in sequence; at 1.3s the badge scales in; at 1.6s the swatches stagger in; at 1.8s the scroll cue appears and begins pulsing. The bottle then floats ±8px on a 6s sine loop. Hovering the primary CTA makes it follow the cursor (magnetic) and spring back on leave; hovering the text link draws a lime underline from the left (and erases it toward the right on leave). Clicking a swatch re-tints the liquid, the glass, the glow and the shadow over 0.8s and swaps the label edition name. Under 900px the section stacks with the bottle above the copy.
**Tech stack:**
- GSAP 3.13+ with SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- CSS: `clip-path`, `color-mix()`, `aspect-ratio`; SVG `<textPath>`.
- Fonts: a variable serif (demo: Fraunces 300, `opsz 144`), a sans for body/CTA, a mono for eyebrow/labels.
**HTML structure:**
```html
<section class="lph" style="--tint:#b8743a">
<div class="lph__grid">
<div class="lph__copy">
<p class="lph__eyebrow">Maison Vellum — Eau de Parfum · No. 07</p>
<h1 class="lph__title">Night-blooming<br>jasmine, in glass</h1>
<p class="lph__para">A 100 ml flacon of hand-poured amber glass…</p>
<div class="lph__buy">
<p class="lph__price"><span class="lph__price-num">€240</span><span class="lph__price-unit">/ 100 ml</span></p>
<div class="lph__ctas">
<button class="lph__cta lph__cta--primary" type="button"><span class="lph__cta-label">Add to cart</span></button>
<a class="lph__cta lph__cta--link" href="#composition">Read the composition<span class="lph__underline" aria-hidden="true"></span></a>
</div>
</div>
<div class="lph__swatches" role="group" aria-label="Edition">
<button class="lph__swatch is-active" type="button" data-tint="#b8743a" data-name="Ambre" style="--sw:#b8743a" aria-pressed="true"><span class="lph__swatch-dot"></span><span class="lph__swatch-name">Ambre</span></button>
<button class="lph__swatch" type="button" data-tint="#3f8a7a" data-name="Vétiver" style="--sw:#3f8a7a" aria-pressed="false">…</button>
<button class="lph__swatch" type="button" data-tint="#8a2f3f" data-name="Rouge" style="--sw:#8a2f3f" aria-pressed="false">…</button>
</div>
</div>
<div class="lph__visual">
<div class="lph__glow" aria-hidden="true"></div>
<div class="lph__reveal"><div class="lph__float">
<div class="lph__bottle" role="img" aria-label="Vellum No. 07 flacon">
<div class="lph__cap"></div><div class="lph__neck"></div>
<div class="lph__body"><div class="lph__liquid"></div><div class="lph__label"><span class="lph__label-name">Vellum</span><span class="lph__label-sub">No. 07 · Ambre</span></div></div>
<div class="lph__shadow"></div>
</div>
</div></div>
<svg class="lph__badge" viewBox="0 0 200 200" aria-hidden="true">
<defs><path id="lph-ring" d="M100,100 m-72,0 a72,72 0 1,1 144,0 a72,72 0 1,1 -144,0"/></defs>
<g class="lph__badge-rotor"><text class="lph__badge-text"><textPath href="#lph-ring" textLength="452" lengthAdjust="spacing">Limited edition · 2026 · Limited edition · 2026 · </textPath></text></g>
<circle cx="100" cy="100" r="3" class="lph__badge-dot"/>
</svg>
</div>
</div>
<div class="lph__cue" aria-hidden="true"><span class="lph__cue-label">Scroll</span><span class="lph__cue-line"></span></div>
</section>
```
Replace the CSS bottle with a transparent PNG/WebP of your product in production; keep `.lph__reveal` → `.lph__float` → `.lph__bottle` as three nested wrappers.
**CSS requirements:**
- `.lph`: `--tint: #b8743a; position: relative; min-height: 100vh; display: grid; align-items: center; padding: clamp(4rem, 8vh, 6rem) 6vw clamp(5rem, 10vh, 7rem); overflow: hidden; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.lph__grid`: `display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: clamp(2rem, 5vw, 6rem); align-items: center; max-width: 1400px; margin-inline: auto;`
- `.lph__title`: serif `font-weight: 300; font-size: clamp(2.6rem, 5.6vw, 5.8rem); line-height: .96; letter-spacing: -0.04em; visibility: hidden;` — `.line { overflow: hidden; padding-bottom: .1em; margin-bottom: -.1em }`.
- `.lph__eyebrow`, `.lph__para`, `.lph__price`, `.lph__cta`, `.lph__swatch`, `.lph__badge`, `.lph__glow`, `.lph__cue` all start at `opacity: 0`.
- `.lph__cta--primary`: `padding: 1em 1.9em; border-radius: 999px; background: var(--text); color: var(--bg); font-weight: 500; will-change: transform;` — `.lph__cta--link { position: relative; padding: .4em 0 }` with `.lph__underline { position: absolute; left: 0; right: 0; bottom: .15em; height: 1px; background: var(--accent); transform: scaleX(0); transform-origin: left center }`.
- `.lph__swatch-dot`: 18px circle, `background: radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--sw) 55%, #fff), var(--sw) 55%, color-mix(in srgb, var(--sw) 70%, #000))`; `.is-active` gets a `1px solid var(--line)` pill border.
- `.lph__visual`: `position: relative; display: grid; place-items: center; min-height: min(78vh, 760px);` — `.lph__glow { position: absolute; inset: 0 -12%; background: radial-gradient(42% 42% at 50% 56%, color-mix(in srgb, var(--tint) 40%, transparent), transparent 70%); filter: blur(12px) }`.
- `.lph__reveal { clip-path: inset(0 0 100% 0); will-change: clip-path; padding: 6% 12% }` (padding so the glow/shadow are not clipped) — `.lph__float { will-change: transform }` — `.lph__bottle { --w: clamp(150px, 15vw, 230px); width: var(--w); display: grid; justify-items: center; transform-origin: 50% 80% }`.
- Bottle parts: `.lph__cap { width: 36%; height: calc(var(--w) * .2); border-radius: 12% 12% 5% 5% / 18% 18% 8% 8%; background: linear-gradient(90deg, #262626, #7a7a7a 22%, #3a3a3a 48%, #8e8e8e 70%, #262626) }`; `.lph__neck { width: 22%; height: calc(var(--w) * .085); background: linear-gradient(90deg, rgba(255,255,255,.06), rgba(255,255,255,.3) 30%, rgba(255,255,255,.04) 70%, rgba(255,255,255,.16)) }`; `.lph__body { width: 100%; aspect-ratio: 23/36; overflow: hidden; border-radius: 13% 13% 17% 17% / 8% 8% 11% 11%; background: linear-gradient(90deg, rgba(0,0,0,.55), transparent 18%, transparent 82%, rgba(0,0,0,.55)), linear-gradient(180deg, color-mix(in srgb, var(--tint) 40%, #0b0b0b), color-mix(in srgb, var(--tint) 70%, #000)); box-shadow: inset 0 0 0 1px rgba(255,255,255,.16), inset 0 -30px 50px rgba(0,0,0,.45), 0 50px 90px -40px color-mix(in srgb, var(--tint) 70%, transparent) }`; `.lph__liquid { position: absolute; inset: 16% 3% 3%; border-radius: inherit; background: linear-gradient(180deg, color-mix(in srgb, var(--tint) 85%, #fff), var(--tint) 18%, color-mix(in srgb, var(--tint) 60%, #000)); opacity: .92 }`; `.lph__body::before` is the highlight strip `linear-gradient(90deg, transparent 9%, rgba(255,255,255,.42) 12%, rgba(255,255,255,.1) 19%, transparent 24%)`; `.lph__label` is a centred cream card (`rgba(244,240,232,.94)`, `width: 56%`, dark text); `.lph__shadow { width: 130%; height: calc(var(--w) * .16); background: radial-gradient(50% 50%, rgba(0,0,0,.6), transparent 70%) }`.
- `.lph__badge { position: absolute; left: 2%; bottom: 10%; width: clamp(96px, 10vw, 140px) }`, `.lph__badge-rotor { transform-origin: 100px 100px }`, `.lph__badge-text { font: 12.5px mono; text-transform: uppercase; fill: var(--text) }`, `.lph__badge-dot { fill: var(--accent) }`.
- `.lph__cue { position: absolute; left: 50%; bottom: 1.75rem; transform: translateX(-50%) }`, `.lph__cue-line { width: 1px; height: 44px; background: var(--accent); transform-origin: top; transform: scaleY(0) }`.
- Under 900px: `.lph__grid { grid-template-columns: 1fr }`, `.lph__visual { order: -1; min-height: 0 }`, `.lph__bottle { --w: clamp(130px, 34vw, 170px) }`, `.lph__title { font-size: clamp(2.4rem, 11vw, 4rem) }`, `.lph__cue { display: none }`.
**Animation spec (exact values):**
Loops (start immediately): `gsap.fromTo('.lph__float', { y: -8 }, { y: 8, duration: 6, ease: 'sine.inOut', yoyo: true, repeat: -1 })`; `gsap.to('.lph__badge-rotor', { rotation: 360, duration: 20, ease: 'none', repeat: -1, transformOrigin: '50% 50%' })`; cue `pulse = gsap.timeline({ repeat: -1, repeatDelay: 0.4, paused: true }).fromTo('.lph__cue-line', { scaleY: 0, transformOrigin: 'top center' }, { scaleY: 1, duration: 0.9, ease: 'power2.inOut' }).set('.lph__cue-line', { transformOrigin: 'bottom center' }).to('.lph__cue-line', { scaleY: 0, duration: 0.9, ease: 'power2.inOut' })`.
Entrance — after `document.fonts.ready`, `SplitText.create('.lph__title', { type: 'lines', mask: 'lines', linesClass: 'line', autoSplit: true, aria: 'auto', onSplit })`; inside `onSplit` build `tl = gsap.timeline({ defaults: { ease: 'power3.out' } })`:
1. `tl.set('.lph__title', { autoAlpha: 1 })`
2. `tl.fromTo('.lph__reveal', { clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0% 0)', duration: 1.4, ease: 'expo.inOut' }, 0)`
3. `tl.fromTo('.lph__bottle', { scale: 1.1 }, { scale: 1, duration: 1.4, ease: 'expo.inOut' }, 0)`
4. `tl.fromTo('.lph__glow', { opacity: 0 }, { opacity: 1, duration: 1.2, ease: 'power2.out' }, 0.6)`
5. `tl.fromTo('.lph__eyebrow', { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8 }, 0.3)`
6. `tl.fromTo(self.lines, { yPercent: 110 }, { yPercent: 0, duration: 1.2, ease: 'power4.out', stagger: 0.1 }, 0.4)`
7. `tl.fromTo('.lph__para', { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: 1 }, 0.9)`
8. `tl.fromTo(['.lph__price', '.lph__cta--primary', '.lph__cta--link'], { y: 16, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, stagger: 0.08 }, 1.1)`
9. `tl.fromTo('.lph__badge', { scale: 0.8, opacity: 0, transformOrigin: '50% 50%' }, { scale: 1, opacity: 1, duration: 0.8 }, 1.3)`
10. `tl.fromTo('.lph__swatch', { y: 10, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, stagger: 0.06 }, 1.6)`
11. Desktop only: `tl.fromTo('.lph__cue', { opacity: 0 }, { opacity: 1, duration: 0.6, onStart: () => pulse.play(0) }, 1.8)`
Return `tl` from `onSplit`.
Swatch click: toggle `.is-active`/`aria-pressed`; `gsap.to(section, { '--tint': btn.dataset.tint, duration: 0.8, ease: 'power2.inOut', overwrite: 'auto' })`; label: `timeline().to('.lph__label-sub', { opacity: 0, duration: 0.2, ease: 'power2.in' }).call(swapText).to('.lph__label-sub', { opacity: 0.7, duration: 0.3, ease: 'power2.out' })`.
Magnetic CTA (`pointermove` on the button): `dx/dy` = cursor minus button centre; `gsap.to(btn, { x: dx * 0.35, y: dy * 0.35, duration: 0.6, ease: 'power3.out', overwrite: 'auto' })` and `gsap.to(label, { x: dx * 0.12, y: dy * 0.12, duration: 0.6, ease: 'power3.out' })`; `pointerleave` → `gsap.to([btn, label], { x: 0, y: 0, duration: 0.9, ease: 'elastic.out(1, 0.45)' })`.
Underline: enter `gsap.fromTo('.lph__underline', { scaleX: 0, transformOrigin: 'left center' }, { scaleX: 1, duration: 0.4, ease: 'power3.out', overwrite: true })`; leave `gsap.to('.lph__underline', { scaleX: 0, transformOrigin: 'right center', duration: 0.4, ease: 'power3.in', overwrite: true })`.
**Interaction:** Magnetic CTA and underline draw only on `(hover: hover) and (pointer: fine)`. Swatches work everywhere (click/tap) and keep `aria-pressed` in sync. The badge and float never stop; the cue pulses with a 0.4s rest between cycles.
**Responsive behavior:** `gsap.matchMedia()` with `(min-width: 900px)` / `(max-width: 899px)`: on mobile the cue tween is omitted (the cue is hidden in CSS) and everything else is identical; the CSS stacks the grid with the bottle first and shrinks it to `clamp(130px, 34vw, 170px)`. The headline is forced to two lines with `<br>` so the line stagger is predictable at every width.
**Accessibility:** The bottle wrapper is `role="img"` with an `aria-label`; the badge, glow, underline and cue are `aria-hidden`. SplitText `aria: 'auto'` keeps the heading readable. Swatches are a `role="group"` of `aria-pressed` buttons with visible focus rings (`2px solid var(--accent)`, offset 4px). Under `prefers-reduced-motion: reduce`: no timeline, no float, no rotation, no pulse — everything is shown at its final state (`clip-path: none`, opacities 1, cue line drawn), swatches set `--tint` instantly, and the underline appears on hover via CSS.
**Performance rules:** Transforms, opacity and one `clip-path` are the only animated properties besides the single `--tint` variable on swatch click (which repaints the gradients — once per click, not per frame). Three nested wrappers (`.lph__reveal` clip, `.lph__float` y-loop, `.lph__bottle` scale) prevent transform overwrites. `will-change` only on those wrappers and the CTA. Everything inside `gsap.context()`, reverted on unmount; the host pauses `gsap.globalTimeline` off-screen (loops included).
**Customization knobs:**
- `--tint` on the section and the three `data-tint` values (any CSS colour).
- `FLOAT_AMPLITUDE` (8px) / `FLOAT_PERIOD` (6s), `BADGE_PERIOD` (20s), `TINT_DURATION` (0.8s).
- `MAGNET` (0.35) / `MAGNET_LABEL` (0.12) strength.
- Reveal duration/ease (1.4s `expo.inOut`) and the headline stagger (0.1).
- Bottle size `--w`, label copy, price and CTA text.
**Acceptance criteria:**
- [ ] Bottle wipes in top-to-bottom while scaling 1.1→1 over 1.4s with `expo.inOut`; no flash of the bottle before JS runs.
- [ ] Two headline lines rise 0.1s apart out of masks (1.2s `power4.out`); eyebrow → paragraph → price/CTA/link → badge → swatches → cue follow at the listed positions.
- [ ] Bottle floats ±8px on a 6s sine loop; the badge completes one rotation every 20s.
- [ ] Swatch click re-tints liquid, glass, glow and shadow together over 0.8s and swaps the label edition name.
- [ ] Primary CTA follows the cursor and springs back; the link underline draws left→right and erases toward the right.
- [ ] Stacks cleanly under 900px and at 375px wide; reduced motion shows the finished hero with no loops; no console errors. Add this effect to my existing site/section: **Luxury Product Hero Section**. Use my existing hero content (eyebrow, headline, paragraph, price, two CTAs, product image, swatches) inside a `.lph` two-column grid that stacks under 900px, with every product colour derived from one `--tint` custom property on the section. Wrap the product in `.lph__reveal` (`clip-path: inset(0 0 100% 0)`) → `.lph__float` → `.lph__bottle`. With GSAP + SplitText (`type: 'lines', mask: 'lines'`) build one timeline: reveal `clipPath → inset(0 0 0% 0)` and `scale 1.1→1` (both 1.4s `expo.inOut`) at 0; eyebrow `y 12→0` at 0.3; headline lines `yPercent 110→0` (1.2s `power4.out`, stagger 0.1) at 0.4; paragraph `y 24→0` (1s) at 0.9; price/CTA/link `y 16→0` (0.8s, stagger 0.08) at 1.1; badge `scale 0.8→1` at 1.3; swatches (0.6s, stagger 0.06) at 1.6; scroll cue at 1.8. Loop the float `y -8↔8` (6s `sine.inOut`, yoyo, repeat -1) and rotate a `<textPath>` badge `rotation: 360` over 20s `ease: 'none'`. Make the primary CTA magnetic (`x/y = offset × 0.35`, 0.6s `power3.out`, elastic return) and draw the link underline `scaleX 0→1` (0.4s). On swatch click `gsap.to(section, { '--tint': colour, duration: 0.8, ease: 'power2.inOut' })`. Wrap in `gsap.context()`, show the final state under `prefers-reduced-motion`, and keep my fonts and colours. Source
Full source code
<section class="lph" style="--tint:#b8743a">
<div class="lph__grid">
<div class="lph__copy">
<p class="lph__eyebrow">Maison Vellum — Eau de Parfum · No. 07</p>
<h1 class="lph__title">Night-blooming<br>jasmine, in glass</h1>
<p class="lph__para">A 100 ml flacon of hand-poured amber glass. Fig leaf and bergamot open, jasmine sambac and iris settle, and a slow base of sandalwood stays until morning.</p>
<div class="lph__buy">
<p class="lph__price"><span class="lph__price-num">€240</span><span class="lph__price-unit">/ 100 ml</span></p>
<div class="lph__ctas">
<button class="lph__cta lph__cta--primary" type="button"><span class="lph__cta-label">Add to cart</span></button>
<a class="lph__cta lph__cta--link" href="#composition">Read the composition<span class="lph__underline" aria-hidden="true"></span></a>
</div>
</div>
<div class="lph__swatches" role="group" aria-label="Edition">
<button class="lph__swatch is-active" type="button" data-tint="#b8743a" data-name="Ambre" style="--sw:#b8743a" aria-pressed="true"><span class="lph__swatch-dot"></span><span class="lph__swatch-name">Ambre</span></button>
<button class="lph__swatch" type="button" data-tint="#3f8a7a" data-name="Vétiver" style="--sw:#3f8a7a" aria-pressed="false"><span class="lph__swatch-dot"></span><span class="lph__swatch-name">Vétiver</span></button>
<button class="lph__swatch" type="button" data-tint="#8a2f3f" data-name="Rouge" style="--sw:#8a2f3f" aria-pressed="false"><span class="lph__swatch-dot"></span><span class="lph__swatch-name">Rouge</span></button>
</div>
</div>
<div class="lph__visual">
<div class="lph__glow" aria-hidden="true"></div>
<div class="lph__reveal">
<div class="lph__float">
<div class="lph__bottle" role="img" aria-label="Vellum No. 07 flacon in amber glass">
<div class="lph__cap"></div>
<div class="lph__neck"></div>
<div class="lph__body">
<div class="lph__liquid"></div>
<div class="lph__label"><span class="lph__label-name">Vellum</span><span class="lph__label-sub">No. 07 · Ambre</span></div>
</div>
<div class="lph__shadow"></div>
</div>
</div>
</div>
<svg class="lph__badge" viewBox="0 0 200 200" aria-hidden="true">
<defs><path id="lph-ring" d="M100,100 m-72,0 a72,72 0 1,1 144,0 a72,72 0 1,1 -144,0"/></defs>
<g class="lph__badge-rotor">
<text class="lph__badge-text"><textPath href="#lph-ring" textLength="452" lengthAdjust="spacing">Limited edition · 2026 · Limited edition · 2026 · </textPath></text>
</g>
<circle cx="100" cy="100" r="3" class="lph__badge-dot"/>
</svg>
</div>
</div>
<div class="lph__cue" aria-hidden="true"><span class="lph__cue-label">Scroll</span><span class="lph__cue-line"></span></div>
</section>
.lph { --tint: #b8743a; position: relative; min-height: 100vh; display: grid; align-items: center; padding: clamp(4rem, 8vh, 6rem) 6vw clamp(5rem, 10vh, 7rem); background: var(--bg); color: var(--text); overflow: hidden; }
.lph__grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: clamp(2rem, 5vw, 6rem); align-items: center; width: 100%; max-width: 1400px; margin-inline: auto; }
/* ---------- copy ---------- */
.lph__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); opacity: 0; }
.lph__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 40;
font-size: clamp(2.6rem, 5.6vw, 5.8rem); line-height: .96; letter-spacing: -0.04em; margin: .55em 0 .5em; visibility: hidden;
}
.lph__title .line { overflow: hidden; padding-bottom: .1em; margin-bottom: -.1em; }
.lph__para { max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.15vw, 1.1rem); line-height: 1.55; opacity: 0; }
.lph__buy { display: flex; flex-wrap: wrap; align-items: center; gap: clamp(1.25rem, 2.5vw, 2.5rem); margin-top: clamp(1.75rem, 3vw, 2.5rem); }
.lph__price { display: flex; align-items: baseline; gap: .5em; opacity: 0; }
.lph__price-num { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.6rem, 2.2vw, 2.2rem); letter-spacing: -0.03em; }
.lph__price-unit { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--muted); }
.lph__ctas { display: flex; flex-wrap: wrap; align-items: center; gap: clamp(1rem, 2vw, 1.75rem); }
.lph__cta { opacity: 0; }
.lph__cta--primary {
display: inline-grid; place-items: center; padding: 1em 1.9em; border-radius: 999px; background: var(--text); color: var(--bg);
font-size: .9rem; font-weight: 500; letter-spacing: -0.01em; will-change: transform;
}
.lph__cta--primary .lph__cta-label { display: block; will-change: transform; }
.lph__cta--primary:focus-visible, .lph__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.lph__cta--link { position: relative; padding: .4em 0; font-size: .9rem; font-weight: 500; text-decoration: none; letter-spacing: -0.01em; }
.lph__cta--link:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 2px; }
.lph__underline { position: absolute; left: 0; right: 0; bottom: .15em; height: 1px; background: var(--accent); transform: scaleX(0); transform-origin: left center; }
.lph__swatches { display: flex; gap: 1.25rem; margin-top: clamp(1.75rem, 3vw, 2.75rem); }
.lph__swatch { display: flex; align-items: center; gap: .5rem; padding: .35rem .6rem .35rem .35rem; border-radius: 999px; border: 1px solid transparent; color: var(--muted); transition: border-color .3s, color .3s; opacity: 0; }
.lph__swatch-dot { width: 18px; height: 18px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--sw) 55%, #fff), var(--sw) 55%, color-mix(in srgb, var(--sw) 70%, #000)); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12); }
.lph__swatch-name { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
.lph__swatch.is-active { border-color: var(--line); color: var(--text); }
.lph__swatch:hover { color: var(--text); }
/* ---------- visual ---------- */
.lph__visual { position: relative; display: grid; place-items: center; min-height: min(78vh, 760px); }
.lph__glow { position: absolute; inset: 0 -12%; background: radial-gradient(42% 42% at 50% 56%, color-mix(in srgb, var(--tint) 40%, transparent), transparent 70%); filter: blur(12px); opacity: 0; pointer-events: none; }
.lph__reveal { clip-path: inset(0 0 100% 0); will-change: clip-path; padding: 6% 12%; }
.lph__float { will-change: transform; }
.lph__bottle { --w: clamp(150px, 15vw, 230px); position: relative; width: var(--w); display: grid; justify-items: center; transform-origin: 50% 80%; will-change: transform; }
.lph__cap {
width: 36%; height: calc(var(--w) * .2); border-radius: 12% 12% 5% 5% / 18% 18% 8% 8%;
background: linear-gradient(90deg, #262626 0%, #7a7a7a 22%, #3a3a3a 48%, #8e8e8e 70%, #262626 100%);
box-shadow: inset 0 1px 0 rgba(255,255,255,.35), inset 0 -3px 6px rgba(0,0,0,.6), 0 2px 2px rgba(0,0,0,.5);
}
.lph__neck {
width: 22%; height: calc(var(--w) * .085); margin-top: -1px;
background: linear-gradient(90deg, rgba(255,255,255,.06), rgba(255,255,255,.3) 30%, rgba(255,255,255,.04) 70%, rgba(255,255,255,.16));
box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
}
.lph__body {
position: relative; width: 100%; aspect-ratio: 23 / 36; overflow: hidden;
border-radius: 13% 13% 17% 17% / 8% 8% 11% 11%;
background:
linear-gradient(90deg, rgba(0,0,0,.55) 0%, transparent 18%, transparent 82%, rgba(0,0,0,.55) 100%),
linear-gradient(180deg, color-mix(in srgb, var(--tint) 40%, #0b0b0b) 0%, color-mix(in srgb, var(--tint) 70%, #000) 100%);
box-shadow:
inset 0 0 0 1px rgba(255,255,255,.16),
inset 0 -30px 50px rgba(0,0,0,.45),
0 50px 90px -40px color-mix(in srgb, var(--tint) 70%, transparent);
}
.lph__liquid {
position: absolute; left: 3%; right: 3%; bottom: 3%; top: 16%; border-radius: inherit;
background: linear-gradient(180deg, color-mix(in srgb, var(--tint) 85%, #fff) 0%, var(--tint) 18%, color-mix(in srgb, var(--tint) 60%, #000) 100%);
opacity: .92;
}
.lph__liquid::before { /* meniscus */
content: ''; position: absolute; left: 6%; right: 6%; top: 0; height: 2px; border-radius: 50%;
background: color-mix(in srgb, var(--tint) 30%, #fff); opacity: .6; filter: blur(.5px);
}
.lph__body::before { /* main highlight strip */
content: ''; position: absolute; inset: 0; z-index: 2; border-radius: inherit;
background: linear-gradient(90deg, transparent 9%, rgba(255,255,255,.42) 12%, rgba(255,255,255,.1) 19%, transparent 24%);
}
.lph__body::after { /* right reflection */
content: ''; position: absolute; inset: 0; z-index: 2; border-radius: inherit;
background: linear-gradient(90deg, transparent 82%, rgba(255,255,255,.18) 86%, transparent 90%);
}
.lph__label {
position: absolute; left: 50%; top: 48%; z-index: 3; transform: translate(-50%, -50%); width: 56%; padding: 7% 4%;
display: grid; justify-items: center; gap: .3em; background: rgba(244,240,232,.94); color: #171512; text-align: center;
box-shadow: 0 1px 0 rgba(0,0,0,.3);
}
.lph__label-name { font-family: var(--font-display); font-weight: 400; font-size: clamp(.95rem, 1.3vw, 1.4rem); letter-spacing: .02em; line-height: 1; }
.lph__label-sub { font-family: var(--font-mono); font-size: clamp(6px, .55vw, 8px); letter-spacing: .12em; text-transform: uppercase; opacity: .7; }
.lph__shadow { width: 130%; height: calc(var(--w) * .16); margin-top: -2%; background: radial-gradient(50% 50% at 50% 50%, rgba(0,0,0,.6), transparent 70%); }
/* badge */
.lph__badge { position: absolute; left: 2%; bottom: 10%; width: clamp(96px, 10vw, 140px); height: auto; opacity: 0; overflow: visible; }
.lph__badge-rotor { transform-origin: 100px 100px; }
.lph__badge-text { font-family: var(--font-mono); font-size: 12.5px; text-transform: uppercase; fill: var(--text); }
.lph__badge-dot { fill: var(--accent); }
/* scroll cue */
.lph__cue { position: absolute; left: 50%; bottom: 1.75rem; transform: translateX(-50%); display: grid; justify-items: center; gap: .6rem; opacity: 0; }
.lph__cue-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: .2em; text-transform: uppercase; color: var(--muted); }
.lph__cue-line { display: block; width: 1px; height: 44px; background: var(--accent); transform-origin: top center; transform: scaleY(0); }
/* ---------- responsive ---------- */
@media (max-width: 899px) {
.lph { padding-block: 3.5rem 5rem; align-items: start; }
.lph__grid { grid-template-columns: 1fr; gap: 1rem; }
.lph__visual { min-height: 0; order: -1; }
.lph__reveal { padding: 4% 10% 0; }
.lph__bottle { --w: clamp(130px, 34vw, 170px); }
.lph__badge { left: 0; bottom: 4%; width: 84px; }
.lph__title { font-size: clamp(2.4rem, 11vw, 4rem); }
.lph__cue { display: none; }
}
/* ---------- reduced motion: final state, no loops ---------- */
@media (prefers-reduced-motion: reduce) {
.lph__title { visibility: visible; }
.lph__eyebrow, .lph__para, .lph__price, .lph__cta, .lph__swatch, .lph__badge, .lph__glow, .lph__cue { opacity: 1; }
.lph__reveal { clip-path: none; }
.lph__cue-line { transform: scaleY(1); }
.lph__cta--link:hover .lph__underline { transform: scaleX(1); }
}
// Luxury Product Hero — masked serif lines, clip-path bottle reveal, float, rotating badge, magnetic CTA, swatch re-tint.
gsap.registerPlugin(SplitText);
const FLOAT_AMPLITUDE = 8; // px, ±
const FLOAT_PERIOD = 6; // seconds, one way
const BADGE_PERIOD = 20; // seconds per rotation
const TINT_DURATION = 0.8;
const MAGNET = 0.35; // button follow strength
const MAGNET_LABEL = 0.12; // label parallax inside the button
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.lph');
const q = gsap.utils.selector(section);
const title = q('.lph__title')[0];
const reveal = q('.lph__reveal')[0];
const bottle = q('.lph__bottle')[0];
const floatEl = q('.lph__float')[0];
const glow = q('.lph__glow')[0];
const badge = q('.lph__badge')[0];
const rotor = q('.lph__badge-rotor')[0];
const primary = q('.lph__cta--primary')[0];
const primaryLabel = q('.lph__cta-label')[0];
const link = q('.lph__cta--link')[0];
const underline = q('.lph__underline')[0];
const swatches = q('.lph__swatch');
const labelSub = q('.lph__label-sub')[0];
const cue = q('.lph__cue')[0];
const cueLine = q('.lph__cue-line')[0];
const ctx = gsap.context(() => {
// ---------- swatches: one CSS variable drives every colour in the scene ----------
const setEdition = (btn) => {
swatches.forEach((s) => { s.classList.toggle('is-active', s === btn); s.setAttribute('aria-pressed', String(s === btn)); });
if (reduced) {
section.style.setProperty('--tint', btn.dataset.tint);
labelSub.textContent = `No. 07 · ${btn.dataset.name}`;
return;
}
gsap.to(section, { '--tint': btn.dataset.tint, duration: TINT_DURATION, ease: 'power2.inOut', overwrite: 'auto' });
gsap.timeline()
.to(labelSub, { opacity: 0, duration: 0.2, ease: 'power2.in' })
.call(() => { labelSub.textContent = `No. 07 · ${btn.dataset.name}`; })
.to(labelSub, { opacity: 0.7, duration: 0.3, ease: 'power2.out' });
};
swatches.forEach((btn) => btn.addEventListener('click', () => setEdition(btn)));
if (reduced) {
gsap.set(title, { autoAlpha: 1 });
gsap.set(['.lph__eyebrow', '.lph__para', '.lph__price', '.lph__cta', '.lph__swatch', '.lph__badge', '.lph__glow', '.lph__cue'], { opacity: 1 });
gsap.set(reveal, { clipPath: 'none' });
gsap.set(cueLine, { scaleY: 1 });
return;
}
// ---------- loops ----------
gsap.fromTo(floatEl, { y: -FLOAT_AMPLITUDE }, { y: FLOAT_AMPLITUDE, duration: FLOAT_PERIOD, ease: 'sine.inOut', yoyo: true, repeat: -1 });
gsap.to(rotor, { rotation: 360, duration: BADGE_PERIOD, ease: 'none', repeat: -1, transformOrigin: '50% 50%' });
const pulse = gsap.timeline({ repeat: -1, repeatDelay: 0.4, paused: true })
.fromTo(cueLine, { scaleY: 0, transformOrigin: 'top center' }, { scaleY: 1, duration: 0.9, ease: 'power2.inOut' })
.set(cueLine, { transformOrigin: 'bottom center' })
.to(cueLine, { scaleY: 0, duration: 0.9, ease: 'power2.inOut' });
// ---------- entrance ----------
const mm = gsap.matchMedia();
mm.add({ desktop: '(min-width: 900px)', mobile: '(max-width: 899px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
let tl;
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines', mask: 'lines', linesClass: 'line', autoSplit: true, aria: 'auto',
onSplit(self) {
tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
tl.set(title, { autoAlpha: 1 })
.fromTo(reveal, { clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0% 0)', duration: 1.4, ease: 'expo.inOut' }, 0)
.fromTo(bottle, { scale: 1.1 }, { scale: 1, duration: 1.4, ease: 'expo.inOut' }, 0)
.fromTo(glow, { opacity: 0 }, { opacity: 1, duration: 1.2, ease: 'power2.out' }, 0.6)
.fromTo('.lph__eyebrow', { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8 }, 0.3)
.fromTo(self.lines, { yPercent: 110 }, { yPercent: 0, duration: 1.2, ease: 'power4.out', stagger: 0.1 }, 0.4)
.fromTo('.lph__para', { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: 1 }, 0.9)
.fromTo(['.lph__price', primary, link], { y: 16, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, stagger: 0.08 }, 1.1)
.fromTo(badge, { scale: 0.8, opacity: 0, transformOrigin: '50% 50%' }, { scale: 1, opacity: 1, duration: 0.8 }, 1.3)
.fromTo(swatches, { y: 10, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, stagger: 0.06 }, 1.6);
if (!mobile) tl.fromTo(cue, { opacity: 0 }, { opacity: 1, duration: 0.6, onStart: () => pulse.play(0) }, 1.8);
return tl;
},
});
});
return () => tl?.kill();
});
// ---------- magnetic CTA + underline draw (pointer devices only) ----------
mm.add('(hover: hover) and (pointer: fine)', () => {
const move = (e) => {
const r = primary.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
gsap.to(primary, { x: dx * MAGNET, y: dy * MAGNET, duration: 0.6, ease: 'power3.out', overwrite: 'auto' });
gsap.to(primaryLabel, { x: dx * MAGNET_LABEL, y: dy * MAGNET_LABEL, duration: 0.6, ease: 'power3.out', overwrite: 'auto' });
};
const leave = () => {
gsap.to([primary, primaryLabel], { x: 0, y: 0, duration: 0.9, ease: 'elastic.out(1, 0.45)', overwrite: 'auto' });
};
const drawIn = () => gsap.fromTo(underline, { scaleX: 0, transformOrigin: 'left center' }, { scaleX: 1, duration: 0.4, ease: 'power3.out', overwrite: true });
const drawOut = () => gsap.to(underline, { scaleX: 0, transformOrigin: 'right center', duration: 0.4, ease: 'power3.in', overwrite: true });
primary.addEventListener('pointermove', move);
primary.addEventListener('pointerleave', leave);
link.addEventListener('pointerenter', drawIn);
link.addEventListener('pointerleave', drawOut);
return () => {
primary.removeEventListener('pointermove', move);
primary.removeEventListener('pointerleave', leave);
link.removeEventListener('pointerenter', drawIn);
link.removeEventListener('pointerleave', drawOut);
};
});
});
window.MotionLab?.onCleanup(() => ctx.revert());