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.
.fd—position:relative; min-height:100svh; background:var(--fd-paper); color:var(--fd-ink); font-family:"DM Sans"..fd-nav—position: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;:activescales to .95. When open:color:#f3f1ec; border-color:var(--fd-line); background:var(--fd-glass)..fd-toggle__maskisdisplay:inline-block; overflow:hiddenso the label can slide vertically..fd-menu—position:fixed; inset:0; z-index:10; background:var(--fd-overlay); overflow:hidden; color:#f3f1ec; pointer-events:none(→autowhen open); initialclip-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-deck—position:absolute; inset:0; display:grid; place-items:center. Each.fd-cardisposition: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. Theimgfills it withobject-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 withmask: 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:0→1on.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 isfont-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__foot—position: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;h1clamp(2.4rem,7vw,7rem), weight 500,letter-spacing:-.03em; line-height:.95;pmax 34ch, opacity .65.@media (max-width:1000px):--fd-card-w:min(58vw,260px), cardsaspect-ratio:5/3.4, dockwidth: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) * spacing→xPercent(108% of card width between centres → -162, -54, 54, 162).fanY(i) = mq.matches ? (i - mid) * 62 : Math.abs(i - mid) * 7→yPercent(a shallow arc on desktop, a vertical stack on mobile).fanR(i) = mq.matches ? (i - mid) * 2.5 : (i - mid) * fanAngle→rotation(−10.5°, −3.5°, 3.5°, 10.5°).
Open timeline (paused, defaults ease: "power3.out"):
0— menuclipPath → OPEN, durationopenDuration(1s).0— glow{ yPercent: 0, scale: 1, opacity: 1 }, durationopenDuration * 1.5,power2.out.0.18— dock{ yPercent: 0, opacity: 1 }, 0.8s.0.28— cards{ yPercent: 0, rotation: -8, scale: 1, filter: "blur(0px)" }, 0.9s,stagger: 0.045(they rise as a leaning stack).0.92— cards{ xPercent: fanX, yPercent: fanY, rotation: fanR }, 1s,ease: "power3.inOut",stagger: { each: 0.035, from: "center" }.1.1— foot{ opacity: 1, y: 0 }, 0.6s.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"):
0— foot{ opacity: 0, y: 10 }, 0.3s.0— cards{ xPercent: 0, yPercent: 0, rotation: -8 }, 0.55s,stagger: { each: 0.03, from: "edges" }(the fan closes from the outside in).0.38— cards{ yPercent: rise, rotation: 14, scale: .92, filter: "blur(14px)" }, 0.55s,stagger: 0.03,ease: "power3.in".0.55— dock{ yPercent: 240, opacity: 0 }, 0.45s,power3.in.0.6— glow{ yPercent: 40, scale: .8, opacity: 0 }, 0.6s.0.72— menuclipPath → CLOSED, 0.7s.
Toggle — setOpen(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 hover — pointermove 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:fixedand covers the page: keep.fd-navabove it (z-index:100) so the toggle stays reachable. - All fan geometry is in
xPercent/yPercent/rotation; pointer parallax lives inx/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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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.