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.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1970 words of exact values — rewritten live as you tune the Playground.

prompt.md · Luxury Product Hero Section
### 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 (tinted `#b8743a` by default) 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, 0.35× the offset) 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; `MAGNET = 0.35`, `MAGNET_LABEL = MAGNET × 0.34` (≈0.12 at the default 0.35); `gsap.to(btn, { x: dx * MAGNET, y: dy * MAGNET, duration: 0.6, ease: 'power3.out', overwrite: 'auto' })` and `gsap.to(label, { x: dx * MAGNET_LABEL, y: dy * MAGNET_LABEL, 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 (#b8743a — the opening edition; a swatch whose `data-tint` matches is shown active) 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.35 × 0.34`) 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.

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>