All components

Ember & Rye Fullscreen Menu

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A minimal Menu button expands a right-docked panel to a fullscreen overlay of five vertical menu columns (Eat, Grill, Burgers, Visit, Order) for a wood-fired burger house, with per-letter staggered reveals of oversized serif labels and image clip-path reveals on hover. Triggered by clicking Menu/Close; GSAP tweens the panel width with a CustomEase 'hop' curve and staggers letter spans with power3.out.

How it's built

Categorymenu
Techgsap
GSAP pluginsCustomEase
Complexitypage
Performance costlight
Mobile-safedesktop-first

fullscreen-menu gsap customease stagger clip-path editorial navigation reveal

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

Editorial Fullscreen Food Menu — Right-Docked Panel Expands to Full-Bleed Columns with Rising Split-Letter Reveal

Goal

Build a magazine-style navigation overlay. A tiny fixed "Menu" button sits over a full-viewport food photo. Clicking it makes a narrow cream panel docked to the right edge expand across the whole screen into five equal vertical columns, while giant condensed-serif labels reveal letter-by-letter, rising up from below each column with a staggered climb. Clicking "Close" reverses everything. Once open, hovering a column reveals a food image (a clip-path box growing from a center point to full rectangle) and swaps the label between a dark version sliding out and a muted duplicate sliding in. The star effect is the combination of the CustomEase "hop" panel-width expansion and the per-letter power3.out staggered vertical reveal of oversized rotated type.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase. No ScrollTrigger, no SplitText (the letter splitting is done by a small hand-written helper, not the plugin), no smooth-scroll library — there is no scroll interaction at all; the whole thing is a fixed-position click-toggled overlay. Import as:

import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
gsap.registerPlugin(CustomEase);

Ship index.html, styles.css, and an ES-module script.js (<script type="module" src="./script.js">).

Layout / HTML

.menu                              (fixed, docked TOP-RIGHT, width:20vw, height:100vh, display:flex, z-index:2)
  .menu-item            × 5        (flex:1 vertical column; one per nav entry)
    .menu-item-index
      <p>01</p>                     (the index number, stays horizontal)
      <p>Why</p>                    (the short word label, rotated -90deg)
    .menu-link.menu-link-main
      <p>Why</p>                    (giant condensed-serif label — dark)
    .menu-link.menu-link-hover
      <p>Why</p>                    (identical giant label — muted taupe, the hover duplicate)
    .menu-img
      <img>                         (per-column food image, hidden until hover)
  .menu-close                       (fixed TOP-RIGHT, z-index:10)
    <p>Close</p>

.container                          (full-viewport landing view; hero food photo as CSS background)
  .menu-open                        (fixed TOP-LEFT, z-index:1)
    <p>Menu</p>

The five items, in order (index number / word label — the same word is repeated three times per item: once in .menu-item-index, once in .menu-link-main, once in .menu-link-hover):

  1. 01 / Why
  2. 02 / Who
  3. 03 / What
  4. 04 / How
  5. 05 / Join

No brand, no logo — the only text on screen is Menu, Close, the five numbers, and the five words. Keep these neutral labels.

Styling

Global / typography

  • * { margin:0; padding:0; box-sizing:border-box; }
  • html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; } — a neutral grotesque for all the small UI text (self-host or substitute any clean sans such as Inter/Helvetica Neue).
  • img { width:100%; height:100%; object-fit:cover; }
  • p { text-transform:uppercase; font-size:12px; font-weight:500; line-height:100%; } — this governs every small label (Menu, Close, index numbers, words).

Color palette (only three colors)

  • Cream / off-white #eee5d2 — the .menu-item background AND the .menu-open ("Menu") text.
  • Muted taupe #a39b89 — the .menu-close text, the .menu-item-index text, the column border (rgba(163,155,137,0.35)), and the hover label (rgba(163,155,137,0.85)).
  • Dark espresso #2c221d — the main giant label text.

Landing view

  • .container { width:100%; height:100%; background:url(<hero>) no-repeat 50% 50%; background-size:cover; } — full-bleed hero food render behind everything.

Toggle buttons (Menu / Close) — clip-path is the reveal mask

  • .menu-open { position:fixed; top:0; left:0; z-index:1; color:#eee5d2; }
  • .menu-close { position:fixed; top:0; right:0; z-index:10; color:#a39b89; }
  • Shared: .menu-open, .menu-close { margin:0.75em; padding:0.75em; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); cursor:pointer; } — the rectangular clip-path acts as an overflow mask: a <p> translated vertically out of the padding box is clipped, giving a text-slide reveal.
  • .menu-open p, .menu-close p { position:relative; }

The panel & columns

  • .menu { position:fixed; top:0; right:0; width:20vw; height:100vh; display:flex; z-index:2; } — starts as a slim right-edge strip; GSAP animates its width.
  • .menu-item { position:relative; flex:1; height:100%; display:flex; justify-content:center; background:#eee5d2; border-left:1.5px solid rgba(163,155,137,0.35); clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); margin-left:-1px; } — the full-rectangle clip-path masks the oversized type that overflows the column.
  • .menu-item-index { height:100%; padding:1.5em 0.5em 2em 0.5em; display:flex; flex-direction:column; justify-content:space-between; align-items:center; color:#a39b89; }
  • .menu-item-index p:nth-child(2) { position:relative; transform:rotate(-90deg); } — the word label at the bottom of the index column reads vertically.

The giant rotated labels

  • .menu-link { position:absolute; bottom:2.5%; left:55%; transform:translate(-50%,-50%) rotate(-90deg); } — anchored near the column's bottom, rotated so the huge text reads vertically (bottom-to-top).
  • Third column exception: .menu-item:nth-child(3) .menu-link { bottom:7.5%; } (the longer word "What" needs a bit more room).
  • .menu-link p { position:relative; display:flex; font-family:"OPTICaslon-ExtraCondensed"; font-size:200px; font-weight:lighter; transition:all 0.5s; } — an extra-condensed serif display face at 200px (substitute any narrow high-contrast serif; the extreme condensation is the point). display:flex so the per-letter <span>s sit in a row.
  • .menu-link p span { position:relative; }
  • .menu-link-main p { color:#2c221d; } (dark)
  • .menu-item .menu-link-main, .menu-item .menu-link-hover { transition:all 0.5s; }
  • .menu-item .menu-link-hover { left:150%; color:rgba(163,155,137,0.85); } — the duplicate hover label starts parked off to the right.

Per-column image

  • .menu-img { position:absolute; width:75%; height:35%; top:25%; left:50%; transform:translate(-50%,-50%); clip-path:polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%); transition:0.5s all cubic-bezier(0.165, 0.84, 0.44, 1); } — the collapsed-to-a-point clip-path hides the image entirely; it lives in the upper portion of the column.

CSS hover interactions (pure CSS transitions — NOT GSAP)

  • Label swap: .menu-item:hover .menu-link-main { left:-100%; } (dark label slides out to the left) and .menu-item:hover .menu-link-hover { left:50%; } (taupe duplicate slides in from 150% to center) — both over the all 0.5s transition, a horizontal label cross-swap.
  • Image reveal (only once the menu is fully open): .menu-item.menu-opened:hover .menu-img { clip-path:polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); } — the clip-path grows from a center point to a full rectangle over 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) (easeOutQuart), unmasking the food image. The .menu-opened class is only present after the open animation completes (see GSAP §3), so images never reveal in the collapsed strip.

GSAP effect (exhaustive)

Setup (runs once on load)
  1. Register the plugin and define the signature ease:

``js CustomEase.create("hop", "M0,0 C0.091,0.543 0.148,0.662 0.277,0.786 0.405,0.909 0.596,0.979 1,1"); `` This "hop" curve rises fast, eases through a soft mid-plateau, then settles — used only for the panel-width tween.

  1. Split every giant label into per-character spans with a hand-rolled helper (no SplitText plugin):

``js function splitTextIntoSpans(selector){ document.querySelectorAll(selector).forEach((el) => { el.innerHTML = el.innerText.split("") .map((ch) => <span>${ch === " " ? "&nbsp;&nbsp;" : ch}</span>).join(""); }); } splitTextIntoSpans(".menu-link p"); ` (A literal space becomes two &nbsp;.) Runs on both .menu-link-main p and .menu-link-hover p`.

  1. Initial GSAP states:
  2. gsap.set(".menu-close p", { y: 40 }) — the "Close" word is pushed 40px down, clipped out of its masked button (hidden on load).
  3. gsap.set(".menu-link p span", { y: 250 }) — every letter is pushed 250px down its own axis, sitting below the column's masked bottom edge (hidden on load).
  4. .menu-open p is left at its default y:0 (the "Menu" word is visible on load).
  5. State flag let isMenuOpen = false;. Wire click on both .menu-open and .menu-close to the same handleMenu handler.
The single toggle handler (handleMenu)

Every click fires the following tweens in parallel. Crucially, isMenuOpen is read *before* it flips — it only flips inside the onComplete of the align-items tween — so on a given click all ternaries use the pre-click value. isMenuOpen === falseOPEN targets; isMenuOpen === trueCLOSE targets.

  1. Panel width (the hero move):

``js gsap.to(menu, { width: isMenuOpen ? "20vw" : "100vw", duration: 1, ease: "hop" }); ` Open: 20vw → 100vw (the right strip stretches across the whole viewport, so the five flex:1 columns each become 20vw wide and fill the screen). Close: 100vw → 20vw. This is the only tween using the "hop"` CustomEase.

  1. Column content alignment:

``js gsap.to(".menu-item", { justifyContent: isMenuOpen ? "center" : "flex-start", duration: 1, ease: "power3.out" }); ` Open: center → flex-start`. Close: reverse. (Animating a flex keyword — GSAP tweens it as a discrete/step change synced to the 1s duration.)

  1. Index column alignment + state flip (drives the .menu-opened class):

``js gsap.to(".menu-item-index", { alignItems: isMenuOpen ? "center" : "flex-start", duration: 1, ease: "power3.out", onComplete: () => { isMenuOpen = !isMenuOpen; menuItems.forEach((it) => it.classList.toggle("menu-opened", isMenuOpen)); }, }); ` Open: center → flex-start. On complete it flips isMenuOpen and adds .menu-opened` to every item (enabling the hover image reveal); on close it removes the class. This is the single source of truth for the toggle state.

  1. "Close" button reveal:

``js gsap.to(".menu-close p", { y: isMenuOpen ? 40 : 0, duration: 1, ease: "power3.out" }); ` Open: y 40 → 0 ("Close" slides up into its masked button). Close: y → 40` (slides back out of view).

  1. "Menu" button hide:

``js gsap.to(".menu-open p", { y: isMenuOpen ? 0 : -40, duration: 1, ease: "power3.out" }); ` Open: y 0 → -40 ("Menu" slides up and out of its mask). Close: y → 0` (slides back in).

  1. Per-letter staggered label reveal (the signature climb): loop over each .menu-item, grab its letters (.menu-link p span), and tween them per column:

``js menuItems.forEach((menuItem) => { const letters = menuItem.querySelectorAll(".menu-link p span"); gsap.to(letters, { delay: isMenuOpen ? 0 : 0.25, y: isMenuOpen ? 250 : 0, duration: 1, stagger: isMenuOpen ? -0.075 : 0.075, ease: "power3.out", }); }); ``

  • Open (isMenuOpen === false): delay 0.25, y 250 → 0 (letters rise up from below the clipped edge into place), stagger 0.075 (forward — letters appear left-to-right along the rotated baseline). The 0.25s delay lets the panel begin expanding before the type climbs.
  • Close (isMenuOpen === true): delay 0, y 0 → 250 (letters drop back down out of view), stagger -0.075 (reverse order). No delay — type retreats immediately.
  • Because both .menu-link-main and .menu-link-hover spans are selected, both label copies rise/fall together. Each of the five columns runs its own independent stagger in parallel, so all columns reveal simultaneously (each internally left-to-right).

There is no timeline object and no ScrollTrigger — everything is concurrent gsap.to/gsap.set calls sharing the same 1s duration, with the 0.25s letter delay on open being the only offset.

Assets / images

Six glossy, stylized 3D-rendered fast-food "icon" images on a solid near-black background (studio-lit, high-saturation, single hero object centered). All are object-fit:cover, so exact aspect ratio is forgiving — supply roughly portrait / square renders (the reveal box is a small landscape-ish window, ~75%×35% of a column). One hero + one per column:

  • hero (.container background, full-bleed): a domed-lid takeaway cup of iced coffee / frappé with a swirl of whipped topping and chocolate drizzle, warm caramel tones. Fills the whole landing viewport behind the "Menu" button.
  • img1 — "Why": a pink-glazed ring donut with rainbow sprinkles, three-quarter view.
  • img2 — "Who": a frothy glass mug of golden lager with an overflowing foam head.
  • img3 — "What": a glossy cheeseburger — sesame bun, melted cheese, tomato, lettuce.
  • img4 — "How": a red carton of thick-cut french fries standing upright.
  • img5 — "Join": a whole pepperoni pizza, top-down.

Any cohesive set of playful, saturated 3D food renders on a dark background works — no brands or logos.

Behavior notes

  • Desktop-oriented (mobileSafe:false). At max-width:900px the CSS reflows: .menu becomes flex-direction:column, labels un-rotate and drop to font-size:80px centered (top/left:50%, rotate(0)), .menu-link-hover and .menu-img are display:none, and the index column centers. The GSAP width tween still runs (20vw→100vw), so on narrow screens the columns stack vertically once expanded.
  • No prefers-reduced-motion branch; no resize listener; no infinite loops. Every animation is a one-shot, 1-second click-driven toggle.
  • The hover label-swap and hover image-reveal are CSS transitions, gated by the .menu-opened class that GSAP adds/removes on toggle completion — so hovering does nothing while the panel is still the collapsed strip.
  • Re-clicking mid-animation is not guarded in the original; the tweens simply retarget. Keep it simple and match that (no re-entrancy lock).
  • Fonts: "PP Neue Montreal" for UI text and "OPTICaslon-ExtraCondensed" for the giant labels. Self-host via @font-face if available, otherwise substitute a clean grotesque + an extra-condensed serif display face; the extreme condensation and 200px scale are essential to the look.