All components

Fan Deck Overlay Menu

GSAP animation component · Published 2026-09-05 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Click-to-open overlay menu: the dark panel wipes up with a clip-path while a glass shelf rises and four frosted image cards climb out of it blurred and tilted, straighten, then fan out with a centre-out stagger. With the menu open the pointer parallaxes the fan and drags a specular highlight across each card's glass; closing gathers the deck back into the shelf before the panel wipes down.

How it's built

Categorymenu
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

overlay menu cards fan glassmorphism clip-path parallax iridescent

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Fan Deck Overlay Menu — Clip-Path Panel, Glass Shelf & Fanned Image Cards

Goal

Build a click-to-open fullscreen overlay menu. When the pill button in the top-right is pressed, a dark panel wipes up from the bottom (animated clip-path polygon) while a soft violet light blooms behind it. A thin glass shelf rises near the bottom, and four frosted-glass image cards climb out of it — blurred, tilted and slightly small — straighten up, then fan out left/right with a centre-out stagger into a row of four with a gentle arc and alternating rotation. With the menu open, moving the pointer parallaxes the cards (outer cards drift more) and a specular highlight follows the cursor across each card's glass; hovering a card scales it up a touch. Closing gathers the fan back into a stack, drops the cards into the shelf, dims the light and wipes the panel back down. Everything is GSAP.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) — core only, no plugins. Fonts: DM Sans (weights 400–600). One script.js, one styles.css, one index.html.

Layout / HTML

Wrap everything in <div class="fd">. Class names are load-bearing.

<div class="fd">
  <nav class="fd-nav">
    <a class="fd-brand" href="#">Lumen</a>
    <button class="fd-toggle" aria-expanded="false" aria-controls="fd-menu">
      <span class="fd-toggle__mask"><span class="fd-toggle__label">Menu</span></span>
    </button>
  </nav>

  <div class="fd-menu" id="fd-menu" aria-hidden="true">
    <div class="fd-menu__glow"></div>
    <div class="fd-deck">
      <!-- ×4: Work / Studio / Journal / Contact, indices 01–04 -->
      <a class="fd-card" href="#">
        <span class="fd-card__inner">
          <img src="…" alt="" />
          <span class="fd-card__label"><span class="fd-card__index">01</span>Work</span>
        </span>
      </a>
    </div>
    <div class="fd-dock"></div>
    <p class="fd-menu__foot"><span>hello@lumen.studio</span><span>Est. 2019</span></p>
  </div>

  <section class="fd-hero">
    <h1>Slide one out</h1>
    <p>A four-card deck lives behind the menu. Open it and the cards rise, settle and fan into place.</p>
  </section>
</div>

Styling

Declare the palette as prefixed custom properties on :root so an editor can override them: --fd-ink:#14161b, --fd-paper:#f3f1ec, --fd-overlay:#15171d, --fd-glow:#7b6cf6, --fd-glass:rgba(255,255,255,.08), --fd-line:rgba(255,255,255,.18), --fd-card-w:clamp(150px,20vw,300px), --fd-hue:0deg. Register --fd-hue with @property { syntax:"<angle>"; inherits:true; initial-value:0deg } so it can be animated smoothly.

  • .fdposition:relative; min-height:100svh; background:var(--fd-paper); color:var(--fd-ink); font-family:"DM Sans".
  • .fd-navposition:fixed; inset:0 0 auto 0; z-index:100; display:flex; justify-content:space-between; align-items:center; padding:1.5rem 2rem. When .fd.is-open, the nav text turns light (#f3f1ec).
  • .fd-toggle — pill: border:1px solid rgba(20,22,27,.14); background:rgba(255,255,255,.45); backdrop-filter:blur(14px) saturate(1.3); border-radius:999px; padding:.7rem 1.1rem; font-size:.85rem; font-weight:500; text-transform:uppercase; letter-spacing:.02em; :active scales to .95. When open: color:#f3f1ec; border-color:var(--fd-line); background:var(--fd-glass). .fd-toggle__mask is display:inline-block; overflow:hidden so the label can slide vertically.
  • .fd-menuposition:fixed; inset:0; z-index:10; background:var(--fd-overlay); overflow:hidden; color:#f3f1ec; pointer-events:none (→ auto when open); initial clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%) (a zero-height sliver at the bottom).
  • .fd-menu__glow — the only background element: position:absolute; left:50%; bottom:-20%; width:70vmax; aspect-ratio:1; transform:translateX(-50%); border-radius:50%; background:radial-gradient(circle at 50% 40%, var(--fd-glow) 0%, transparent 60%); filter:blur(60px); opacity:.55.
  • .fd-deckposition:absolute; inset:0; display:grid; place-items:center. Each .fd-card is position:absolute; width:var(--fd-card-w); aspect-ratio:5/4; transform-origin:50% 120% (so rotation pivots below the card, like a hand of cards).
  • .fd-card__inner — the glass: display:block; width:100%; height:100%; padding:6px; border-radius:18px; background:var(--fd-glass); border:1px solid var(--fd-line); backdrop-filter:blur(18px) saturate(1.4); box-shadow:0 30px 60px -30px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.22); overflow:hidden. The img fills it with object-fit:cover; border-radius:12px.
  • ::after = iridescent edge: inset:0; border-radius:inherit; padding:1px; background:conic-gradient(from var(--fd-hue), transparent 0deg, rgba(178,196,255,.7) 70deg, rgba(255,184,226,.55) 140deg, transparent 200deg, rgba(160,255,228,.6) 280deg, transparent 360deg), clipped to the 1px ring with mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude (-webkit-mask-composite: xor); opacity:.55; pointer-events:none.
  • ::before = specular highlight: radial-gradient(120px 120px at var(--fd-mx,50%) var(--fd-my,0%), rgba(255,255,255,.22), transparent 70%), mix-blend-mode:screen, opacity:01 on .fd-card:hover (0.4s transition), z-index:1.
  • .fd-card__label — glass chip bottom-left: position:absolute; left:14px; bottom:14px; padding:.45em .8em; border-radius:999px; background:rgba(10,12,16,.42); border:1px solid rgba(255,255,255,.14); backdrop-filter:blur(10px); font-size:.85rem; font-weight:500; z-index:2; the index span is font-size:.65em; opacity:.6.
  • .fd-dock — the shelf: position:absolute; left:50%; bottom:17%; width:min(56vw,720px); height:12px; transform:translateX(-50%); border-radius:999px; background:linear-gradient(180deg, rgba(255,255,255,.16), rgba(255,255,255,.05)); border:1px solid var(--fd-line); box-shadow:0 20px 40px -18px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.3); z-index:3.
  • .fd-menu__footposition:absolute; left/right:2rem; bottom:1.6rem; display:flex; justify-content:space-between; font-size:.75rem; letter-spacing:.04em; text-transform:uppercase; opacity:.55.
  • .fd-hero — centred column, min-height:100svh; h1 clamp(2.4rem,7vw,7rem), weight 500, letter-spacing:-.03em; line-height:.95; p max 34ch, opacity .65.
  • @media (max-width:1000px): --fd-card-w:min(58vw,260px), cards aspect-ratio:5/3.4, dock width:70vw; bottom:10%, nav/foot padding 1.25rem.

GSAP effect (be exact)

Write a mount(config) function that builds everything and returns a destroy(); call it on DOMContentLoaded with these defaults:

const DEFAULTS = { openDuration: 1, fanAngle: 7, spacing: 108, rise: 140, entryBlur: 14, parallax: 14, hoverScale: 1.05 };

Constants: CLOSED = "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", OPEN = "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", mid = (cards.length - 1) / 2, mq = matchMedia("(max-width: 1000px)").

Initial state (gsap.set) — menu clipPath: CLOSED; cards { yPercent: rise, xPercent: 0, rotation: 14, scale: .92, x: 0, y: 0, filter: "blur(14px)" }; glow { yPercent: 40, scale: .8, opacity: 0 }; dock { yPercent: 240, opacity: 0 }; foot { opacity: 0, y: 10 }.

Fan targets are function-based so they re-evaluate on every open (timeline.invalidate() before restart()):

  • fanX(i) = mq.matches ? 0 : (i - mid) * spacingxPercent (108% of card width between centres → -162, -54, 54, 162).
  • fanY(i) = mq.matches ? (i - mid) * 62 : Math.abs(i - mid) * 7yPercent (a shallow arc on desktop, a vertical stack on mobile).
  • fanR(i) = mq.matches ? (i - mid) * 2.5 : (i - mid) * fanAnglerotation (−10.5°, −3.5°, 3.5°, 10.5°).

Open timeline (paused, defaults ease: "power3.out"):

  1. 0 — menu clipPath → OPEN, duration openDuration (1s).
  2. 0 — glow { yPercent: 0, scale: 1, opacity: 1 }, duration openDuration * 1.5, power2.out.
  3. 0.18 — dock { yPercent: 0, opacity: 1 }, 0.8s.
  4. 0.28 — cards { yPercent: 0, rotation: -8, scale: 1, filter: "blur(0px)" }, 0.9s, stagger: 0.045 (they rise as a leaning stack).
  5. 0.92 — cards { xPercent: fanX, yPercent: fanY, rotation: fanR }, 1s, ease: "power3.inOut", stagger: { each: 0.035, from: "center" }.
  6. 1.1 — foot { opacity: 1, y: 0 }, 0.6s.
  7. 0 — root { "--fd-hue": "360deg", duration: 6, ease: "none", repeat: -1 } (the iridescent edges slowly rotate while open).

Close timeline (paused, defaults ease: "power3.inOut"):

  1. 0 — foot { opacity: 0, y: 10 }, 0.3s.
  2. 0 — cards { xPercent: 0, yPercent: 0, rotation: -8 }, 0.55s, stagger: { each: 0.03, from: "edges" } (the fan closes from the outside in).
  3. 0.38 — cards { yPercent: rise, rotation: 14, scale: .92, filter: "blur(14px)" }, 0.55s, stagger: 0.03, ease: "power3.in".
  4. 0.55 — dock { yPercent: 240, opacity: 0 }, 0.45s, power3.in.
  5. 0.6 — glow { yPercent: 40, scale: .8, opacity: 0 }, 0.6s.
  6. 0.72 — menu clipPath → CLOSED, 0.7s.

TogglesetOpen(next): toggles .is-open on .fd, aria-expanded/aria-hidden, swaps the label (see below) and either closeTl.pause(0); openTl.invalidate().restart() or openTl.pause(); closeTl.invalidate().restart(). Escape closes.

Label swap — a tiny timeline: label yPercent → -110 (0.25s, power2.in), set textContent to "Close"/"Menu", then fromTo yPercent 110 → 0 (0.35s, power3.out).

Pointer parallax (only when open and not mobile) — on pointermove over the window, nx = clientX/innerWidth - .5, ny = clientY/innerHeight - .5; for each card depth = 1 + |i - mid| * .35, then quickTo(card,"x")(nx * parallax * depth) and quickTo(card,"y")(ny * parallax * .6 * depth) with { duration: .8, ease: "power3" }. x/y are independent of the xPercent/yPercent fan, so they stack cleanly.

Card hoverpointermove on a card writes --fd-mx/--fd-my (percent position inside the card) onto .fd-card__inner for the specular highlight; pointerenter tweens the inner to scale: hoverScale (0.6s power3.out, overwrite:"auto"), pointerleave back to 1.

destroy() — remove all listeners, kill both timelines and any tweens, clearProps:"all" on menu/glow/dock/foot/label/cards/inners, remove --fd-hue, reset classes/aria/label.

Assets / images

Four landscape photos (≈3:2, ~600×400 is enough) that read as *sections of a studio site*: a workspace, an interior, a stack of books, two people talking. Muted, natural light; they sit inside frosted frames so avoid busy backgrounds.

Behavior notes

  • The overlay is position:fixed and covers the page: keep .fd-nav above it (z-index:100) so the toggle stays reachable.
  • All fan geometry is in xPercent/yPercent/rotation; pointer parallax lives in x/y. Don't mix them.
  • Mobile (<1000px): cards stack vertically with a slight rotation, no pointer parallax; touch still opens/closes.
  • prefers-reduced-motion: disable the highlight transition; the timelines still run (they are short).
  • Nothing in the component is :root-scoped except the prefixed variables, so it can be dropped into an existing page.

Images

This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/fan-deck-overlay-menu/contact.jpg
https://motionprompts.dev/c/fan-deck-overlay-menu/journal.jpg
https://motionprompts.dev/c/fan-deck-overlay-menu/studio.jpg
https://motionprompts.dev/c/fan-deck-overlay-menu/work.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--fd-ink, --fd-paper, --fd-overlay, --fd-glow, --fd-glass, --fd-line, --fd-card-w, --fd-hue. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, *::before, *::after, html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.