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):
01/ Why02/ Who03/ What04/ How05/ 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-itembackground AND the.menu-open("Menu") text. - Muted taupe
#a39b89— the.menu-closetext, the.menu-item-indextext, 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 rectangularclip-pathacts 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 itswidth..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-rectangleclip-pathmasks 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:flexso 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-pointclip-pathhides 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 from150%to center) — both over theall 0.5stransition, 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 over0.5s cubic-bezier(0.165, 0.84, 0.44, 1)(easeOutQuart), unmasking the food image. The.menu-openedclass 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)
- 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.
- 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 === " " ? " " : ch}</span>).join(""); }); } splitTextIntoSpans(".menu-link p"); ` (A literal space becomes two .) Runs on both .menu-link-main p and .menu-link-hover p`.
- Initial GSAP states:
gsap.set(".menu-close p", { y: 40 })— the "Close" word is pushed 40px down, clipped out of its masked button (hidden on load).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)..menu-open pis left at its defaulty:0(the "Menu" word is visible on load).- State flag
let isMenuOpen = false;. Wireclickon both.menu-openand.menu-closeto the samehandleMenuhandler.
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 === false ⇒ OPEN targets; isMenuOpen === true ⇒ CLOSE targets.
- 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.
- 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.)
- Index column alignment + state flip (drives the
.menu-openedclass):
``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.
- "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).
- "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).
- 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-mainand.menu-link-hoverspans 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 (
.containerbackground, 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). Atmax-width:900pxthe CSS reflows:.menubecomesflex-direction:column, labels un-rotate and drop tofont-size:80pxcentered (top/left:50%,rotate(0)),.menu-link-hoverand.menu-imgaredisplay: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-motionbranch; 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-openedclass 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-faceif available, otherwise substitute a clean grotesque + an extra-condensed serif display face; the extreme condensation and 200px scale are essential to the look.