Draggable Pill Menu — Drag-Anywhere Drawer + Snap-Back Drop Zone + Expanding Hamburger
Goal
Build a small pill-shaped floating menu drawer docked in the top-left corner. Two things make it: (1) the whole pill is draggable anywhere in the viewport with GSAP Draggable, and when you drag it back near its origin a dashed "drop-zone" outline fades in and the pill springs back into its corner on release; (2) clicking the hamburger toggler expands the pill — the nav-items row grows from width: 0 to its full width while each link fades + scales in with a staggered pop, and the toggler bars morph into an X. Closing reverses it, with the links popping out from the end first. It is entirely click- and drag-driven — no scroll, no autoplay.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin Draggable. No ScrollTrigger, no smooth-scroll library. Import as:
import gsap from "gsap";
import { Draggable } from "gsap/all";
gsap.registerPlugin(Draggable);
Layout / HTML
.menu-drop-zone (fixed dashed outline behind the drawer — the snap target ghost)
.menu-drawer (fixed pill container — this whole element is the drag handle)
.menu-logo
img (small square logo mark)
.menu-items (the collapsible links row — animated width)
.menu-item > a "Work"
.menu-item > a "Manifesto"
.menu-item > a "Contact"
.menu-toggler (hamburger button — click target to expand/collapse)
span
span
Use neutral, fictional link labels — Work, Manifesto, Contact. No real brand names.
Styling
Font (Google Fonts): Google Sans (variable, weights 400–700, italic + optical-size axes). body { font-family: "Google Sans", sans-serif; }.
Color tokens:
--base-100: #fafafa(menu-item pill fill — near white)--base-200: #e8e6e7(drawer background — light grey)--base-300: #d3d2d2(toggler background — mid grey)--base-400: #0f0f0f(text + hamburger bars — near black)
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. Everything is rem-based (assume 1rem = 16px). The JS reads pixel widths off the DOM, so keep these dimensions:
Structural / load-bearing CSS:
.menu-drop-zone:position: fixed; top: 2rem; left: 2rem; height: calc(3.5rem + 0.7rem);border: 0.075rem dashed rgba(0,0,0,0.5); border-radius: 4rem;transition: opacity 0.2s ease-out;pointer-events: none; opacity: 0;. (Its width is set by JS, not CSS — it is a dashed ghost the exact size of the pill, sitting under the drawer's home position, invisible until you drag near home.).menu-drawer:position: fixed; top: 2rem; left: 2rem; padding: 0.35rem;background: var(--base-200); border-radius: 4rem;display: flex; align-items: center;. This element is the GSAP Draggable target..menu-logo:width: 6rem; height: 3.5rem; padding-left: 0.5rem; border-radius: 4rem;flex-centered,flex-shrink: 0;..menu-logo img { width: 3.5rem; }..menu-items:display: flex; gap: 0.35rem;(this is the element whosewidth/marginRightare animated; it starts at width 0 so its links are clipped away)..menu-items .menu-item:width: max-content; height: 3.5rem;background: var(--base-100); border-radius: 4rem;flex-centered,flex-shrink: 0; opacity: 0;..menu-item a:text-decoration:none; color: var(--base-400); font-weight: 450; letter-spacing: -0.01rem; padding: 0 1.5rem; user-select: none;..menu-toggler:position: relative; width: 3.5rem; height: 3.5rem; padding: 1.125rem;background: var(--base-300); border-radius: 4rem;display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 0.2rem;flex-shrink: 0; cursor: pointer;..menu-toggler span:position: relative; width: 100%; height: 0.125rem; background: var(--base-400);transition: transform 0.3s ease; transform-origin: center; will-change: transform;(two thin black bars = the hamburger).
Hamburger→X morph is pure CSS (a transition, not GSAP) driven by an .close class the JS toggles on .menu-toggler:
.menu-toggler.close span:nth-child(1) {
transform: rotate(45deg) translateX(0.125rem) translateY(0.1rem) scaleX(0.9);
}
.menu-toggler.close span:nth-child(2) {
transform: rotate(-45deg) translateX(0.125rem) translateY(-0.1rem) scaleX(0.9);
}
GSAP effect (exhaustive)
0. Measure the pill widths in JS
Grab refs: .menu-drop-zone, .menu-drawer, .menu-logo, .menu-items, all .menu-items, .menu-toggler. Track let isMenuOpen = false;.
Read live pixel dimensions and derive the two pill widths (padding and gap are the CSS 0.35rem = 0.35 * 16 = 5.6px):
let menuItemsFullWidth = menuItems.offsetWidth; // natural width of the links row
const drawerGap = 0.35 * 16;
const drawerPadding = 0.35 * 16;
const logoWidth = menuLogo.offsetWidth; // 6rem box
const togglerWidth = menuToggler.offsetWidth; // 3.5rem box
const closedMenuWidth = drawerPadding + logoWidth + drawerGap + togglerWidth + drawerPadding;
let openMenuWidth = drawerPadding + logoWidth + drawerGap + menuItemsFullWidth + drawerGap + togglerWidth + drawerPadding;
1. Initial (closed) states — set immediately
gsap.set(menuItems, { width: 0, marginRight: 0 }); // collapse the links row
gsap.set(menuItemElements, { opacity: 0, scale: 0.85 }); // links hidden + slightly shrunk
gsap.set(menuDropZone, { width: closedMenuWidth }); // ghost matches the closed pill
2. Re-measure after the web font loads
"Google Sans" arrives from the CDN after first layout and changes the measured text width, so re-measure menuItemsFullWidth once fonts are ready (only if the menu is still closed):
document.fonts.ready.then(() => {
if (isMenuOpen) return;
gsap.set(menuItems, { width: "auto" });
menuItemsFullWidth = menuItems.offsetWidth;
gsap.set(menuItems, { width: 0 });
openMenuWidth = drawerPadding + logoWidth + drawerGap + menuItemsFullWidth + drawerGap + togglerWidth + drawerPadding;
});
3. Toggle wiring
menuToggler click → toggleMenu(): if isMenuOpen call closeMenu() else openMenu(), then flip isMenuOpen.
openMenu() — add class close to the toggler (CSS morphs bars → X), then:
gsap.to(menuItems, {
width: menuItemsFullWidth,
marginRight: drawerGap,
duration: 0.5,
ease: "power3.inOut",
onStart: () => {
gsap.to(menuItemElements, {
opacity: 1, scale: 1,
duration: 0.3,
stagger: 0.05,
delay: 0.2,
ease: "power3.out",
});
},
});
So the links row expands width: 0 → menuItemsFullWidth (and marginRight 0 → 5.6px) over 0.5s power3.inOut; onStart kicks off a link-pop tween that (after a 0.2s delay) fades each .menu-item opacity 0→1 and scale 0.85→1 over 0.3s power3.out with a 0.05s forward stagger (Work, then Manifesto, then Contact).
closeMenu() — remove class close, then:
gsap.to(menuItems, {
width: 0,
marginRight: 0,
duration: 0.5,
ease: "power3.inOut",
onStart: () => {
gsap.to(menuItemElements, {
opacity: 0, scale: 0.85,
duration: 0.3,
ease: "power3.out",
stagger: { each: 0.05, from: "end" },
});
},
});
Same 0.5s power3.inOut collapse of the row; links fade/scale back out over 0.3s power3.out but with stagger: { each: 0.05, from: "end" } so Contact pops out first, then Manifesto, then Work (no delay this time). The toggler X un-morphs back to a hamburger via the CSS transition.
4. Draggable + snap-back drop zone
const snapThreshold = 200; // px
Draggable.create(menuDrawer, {
type: "x,y",
bounds: window,
cursor: "grab",
activeCursor: "grabbing",
onDragStart: function () {
// size the ghost to whichever pill state is current
const activeMenuWidth = isMenuOpen ? openMenuWidth : closedMenuWidth;
gsap.set(menuDropZone, { width: activeMenuWidth });
},
onDrag: function () {
const withinSnap = Math.abs(this.x) < snapThreshold && Math.abs(this.y) < snapThreshold;
gsap.to(menuDropZone, { opacity: withinSnap ? 1 : 0, duration: 0.1 });
},
onDragEnd: function () {
gsap.to(menuDropZone, { opacity: 0, duration: 0.1 });
const withinSnap = Math.abs(this.x) < snapThreshold && Math.abs(this.y) < snapThreshold;
if (withinSnap) {
gsap.to(menuDrawer, { x: 0, y: 0, duration: 0.3, ease: "power2.out" });
}
},
});
- type
"x,y"— free 2D drag;bounds: windowkeeps the pill inside the viewport; cursor isgrabat rest,grabbingwhile dragging. this.x/this.yare the Draggable transform offsets relative to the home (top-left) position, so|x| < 200 && |y| < 200means "within 200px of home."- onDragStart sets the ghost's width to match the currently open or closed pill (so the outline is the right size to snap into).
- onDrag fades the dashed
.menu-drop-zoneghost toopacity 1when inside the 200px snap radius,0when outside — each a fast 0.1s tween. (The CSS also has a0.2sopacity transition, but the GSAP tween drives it here.) - onDragEnd hides the ghost (0.1s), and if released inside the snap radius springs the drawer home with
{ x: 0, y: 0, duration: 0.3, ease: "power2.out" }. Released outside the radius, the pill just stays where it was dropped.
Assets / images
1 image, role = *logo mark inside the drawer pill*. A single small square/wordmark logo on a transparent background (SVG or PNG), displayed at 3.5rem (≈56px) wide inside a 6rem logo box. A bold, dark, geometric mark reads best against the light-grey pill. Any roughly 1:1 mark works; no real brand.
Behavior notes
- Entirely click + drag driven — no scroll, no ScrollTrigger, no loops, no autoplay.
- The
.menu-drop-zoneghost ispointer-events: noneandopacity: 0at rest; it only appears mid-drag inside the 200px snap radius, and its width is always assigned by JS, never CSS. - The pill can be dragged while open or closed;
onDragStartre-sizes the ghost to the matching state, and the width re-measure ondocument.fonts.readybails out if the menu is already open (to avoid clobbering an in-progress open width). - No re-entrancy guard — GSAP overwrites in-flight tweens naturally if the toggler is clicked mid-animation.