A single element that transforms from a compact MENU button into a full navigation panel. Built with CSS + GSAP.
← Click the red MENU button
Add GSAP before your closing </body> tag:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
All selectors use data-menu attributes, making them
portable to any builder (Webstudio, Webflow, etc.).
:root {
--menu-red: #e30613;
--menu-off-white: #edebe7;
--menu-black: #111;
}
/* Container */
[data-menu="root"] {
position: fixed;
top: 28px;
right: 32px;
z-index: 100;
width: 12ch;
border: 3px solid var(--menu-red);
background: var(--menu-red);
display: flex;
flex-direction: column;
overflow: hidden;
transition: background 0.3s ease, border-color 0.3s ease;
}
/* Toggle button */
[data-menu="toggle"] {
display: inline-flex;
align-items: stretch;
cursor: pointer;
border: none;
background: transparent;
padding: 0;
outline: none;
flex-shrink: 0;
}
/* Hamburger icon box */
[data-menu="icon"] {
width: 42px;
height: 42px;
background: var(--menu-red);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
/* Hamburger lines */
[data-menu="line"] {
display: block;
width: 20px;
height: 2px;
background: #fff;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Label text */
[data-menu="label"] {
display: flex;
align-items: center;
padding: 0 16px;
font-family: "Anton", sans-serif;
font-size: 15px;
letter-spacing: 2px;
text-transform: uppercase;
color: #fff;
white-space: nowrap;
transition: color 0.3s ease;
}
/* Closed hover: invert colors */
[data-menu="root"]:not(.is-open):hover {
background: #fff;
border-color: var(--menu-red);
}
[data-menu="root"]:not(.is-open):hover [data-menu="label"] {
color: var(--menu-red);
}
/* Open hover: minus line rotates */
[data-menu="root"].is-open:hover [data-menu="line"] {
transform: rotate(-45deg);
}
/* Expandable body */
[data-menu="body"] {
height: 0;
overflow: hidden;
background: var(--menu-off-white);
display: flex;
flex-direction: column;
}
/* Nav links */
[data-menu="nav"] {
padding: 32px 40px;
display: flex;
flex-direction: column;
overflow: hidden;
}
[data-menu="link"] {
display: block;
font-family: "Anton", sans-serif;
font-size: clamp(50px, 9vw, 78px);
color: var(--menu-black);
text-decoration: none;
text-transform: uppercase;
line-height: 1.05;
letter-spacing: -1px;
overflow: hidden;
transition: color 0.25s ease;
}
[data-menu="link"] span { display: inline-block; }
[data-menu="link"]:hover { color: var(--menu-red); }
Build this element tree. Each data-menu value tells the
JS which role the element plays.
<div data-menu="root">
<button data-menu="toggle">
<div data-menu="icon">
<span data-menu="line"></span>
<span data-menu="line"></span>
<span data-menu="line"></span>
</div>
<span data-menu="label">Menu</span>
</button>
<div data-menu="body">
<nav data-menu="nav">
<a href="#" data-menu="link"><span>Home</span></a>
<a href="#" data-menu="link"><span>Services</span></a>
<a href="#" data-menu="link"><span>Project</span></a>
<a href="#" data-menu="link"><span>About</span></a>
<a href="#" data-menu="link"><span>Contact</span></a>
</nav>
</div>
</div>
Paste this after the GSAP CDN script. The JS is fully self-contained
and queries elements by data-menu attributes.
document.addEventListener("DOMContentLoaded", function () {
var root = document.querySelector('[data-menu="root"]');
var toggle = document.querySelector('[data-menu="toggle"]');
var label = document.querySelector('[data-menu="label"]');
var icon = document.querySelector('[data-menu="icon"]');
var lines = document.querySelectorAll('[data-menu="line"]');
var menuBody = document.querySelector('[data-menu="body"]');
var navSpans = document.querySelectorAll('[data-menu="link"] span');
if (!root || !toggle) return;
var line1 = lines[0];
var line3 = lines[2];
var isOpen = false;
var openTl, closeTl;
var closedWidth = root.offsetWidth;
function openMenu() {
if (isOpen) return;
isOpen = true;
root.classList.add("is-open");
if (closeTl) closeTl.kill();
openTl = gsap.timeline({ defaults: { ease: "power3.out" } });
openTl
.to(root, {
borderWidth: 3,
width: Math.min(480, window.innerWidth * 0.92),
background: "#fff",
duration: 0.45,
ease: "power3.inOut",
})
.to([line1, line3], { opacity: 0, duration: 0.25, ease: "power2.in" }, 0)
.to(icon, { gap: 0, duration: 0.25 }, 0)
.to(label, { color: "#e30613", duration: 0.3 }, 0.1)
.add(function () { label.textContent = "Close"; }, 0.15)
.to(menuBody, { height: "auto", duration: 0.55, ease: "power3.inOut" }, 0.2)
.set(toggle, { borderBottom: "2px solid #e30613" }, 0.2)
.fromTo(
navSpans,
{ y: 70, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.5, stagger: 0.06, ease: "power3.out" },
0.4
);
}
function closeMenu() {
if (!isOpen) return;
isOpen = false;
if (openTl) openTl.kill();
closeTl = gsap.timeline({
defaults: { ease: "power3.inOut" },
onComplete: function () {
root.classList.remove("is-open");
gsap.set([root, toggle, label, menuBody], { clearProps: "all" });
gsap.set(navSpans, { clearProps: "all" });
gsap.set([line1, line3], { clearProps: "all" });
gsap.set(icon, { clearProps: "all" });
},
});
closeTl
.to(navSpans, {
y: -30, opacity: 0, duration: 0.25, stagger: 0.03, ease: "power2.in"
}, 0)
.to(menuBody, { height: 0, duration: 0.4, ease: "power3.inOut" }, 0.15)
.set(toggle, { borderBottom: "none" }, 0.15)
.to(root, {
borderWidth: 3, width: closedWidth, background: "#e30613",
duration: 0.4, ease: "power3.inOut"
}, 0.2)
.to(label, { color: "#fff", duration: 0.25 }, 0.25)
.add(function () { label.textContent = "Menu"; }, 0.3)
.to(icon, { gap: 5, duration: 0.25 }, 0.25)
.to([line1, line3], { opacity: 1, duration: 0.25, ease: "power2.out" }, 0.3);
}
toggle.addEventListener("click", function () {
isOpen ? closeMenu() : openMenu();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && isOpen) closeMenu();
});
});
| Attribute | Element | Description |
|---|---|---|
data-menu="root" |
Outer container | The fixed-position wrapper. Morphs between button and panel. |
data-menu="toggle" |
Button | Clickable trigger. Receives the border-bottom separator when open. |
data-menu="icon" |
Box inside button | Contains the 3 hamburger lines. Gap animates to 0 on open. |
data-menu="line" |
3 × Span | Hamburger lines. Lines 1 & 3 fade out, line 2 stays as minus. |
data-menu="label" |
Span | Text that swaps between "Menu" and "Close". |
data-menu="body" |
Box | Expandable container. Height tweens from 0 to auto. |
data-menu="nav" |
Nav / Box | Wraps the navigation links with padding. |
data-menu="link" |
Link (anchor) | Each nav item. Must contain a <span> for the stagger animation. |
The open/close is a single GSAP timeline with these phases:
| Time | Open | Close |
|---|---|---|
0s |
Container widens, bg → white. Lines 1 & 3 fade out, icon gap → 0 | Nav items slide up & fade. Footer fades. |
0.1s |
Label color → red | — |
0.15s |
Label text swaps to "Close" | Body collapses. Separator removed. |
0.2s |
Body expands. Red separator appears. | Container shrinks, bg → red. |
0.4s |
Nav items stagger in (y: 70 → 0) | Label text → "Menu", lines fade back in. |
In Webstudio, add custom attributes via Settings panel → Custom Attributes on each element. The CSS and JS go into separate HTML Embed components.
| What | Where in Webstudio |
|---|---|
Google Font <link> |
Project Settings → Custom Code → Head |
CSS <style> block |
HTML Embed at top of page (or Head code) |
| Element tree |
Visual editor — add data-menu attributes via Settings
|
GSAP <script> + JS |
HTML Embed at bottom of page |
/* Change the brand color */
:root {
--menu-red: #your-color;
}
/* Change open panel width (in the JS) */
width: Math.min(480, window.innerWidth * 0.92) /* ← change 480 */
/* Change closed button width */
[data-menu="root"] {
width: 12ch; /* ← change this value */
}
/* Change nav font size */
[data-menu="link"] {
font-size: clamp(50px, 9vw, 78px); /* ← adjust clamp values */
}