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 "Studio"
.menu-item > a "Contact"
.menu-toggler (hamburger button — click target to expand/collapse)
span
span
Use neutral, fictional link labels — Work, Studio, Contact. No real brand names. Around the pill the demo lays out a static page (a headline, a lede, small mono labels at the corners) so the drag has a room to happen in; none of it animates.
Styling
Fonts: Inter for the page and the links, Space Mono for the small uppercase perimeter labels (.stage-tag, .eyebrow, .ground), Space Grotesk for the headline.
Color tokens — bone page, near-black ink, and two accents that only ever appear as small areas (cobalt for links and the drag mark, lime for the toggler you press):
:root {
--paper: #f0ede4;
--paper-lit: #f7f5ef;
--paper-edge: #e4e1d5;
--ink: #0d0d0d;
--muted: #5f6368;
--accent: #1141ff; /* cobalt */
--accent-lime: #c6f21e;
--pill-shell: #fbfaf5; /* the pill: flat warm-white shell… */
--pill-toggle: #c6f21e; /* …lime where you press */
}
body sits on var(--paper) under a wide radial-gradient highlight, so the page reads as lit paper rather than flat fill.
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-color: var(--pill-shell); border: 1px solid rgba(13,13,13,.12); border-radius: 4rem; box-shadow: 0 8px 24px rgba(13,13,13,.1); z-index: 30;display: flex; align-items: center;. This element is the GSAP Draggable target. The0.35rempadding is read by the JS — do not change it..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; border-radius: 4rem;flex-centered,flex-shrink: 0; opacity: 0;(no fill of its own — the items read directly on the pill shell)..menu-item a:text-decoration:none; color: var(--ink); font-weight: 600; font-size: 1rem; letter-spacing: -0.005em; padding: 0 1.5rem; user-select: none;and turnsvar(--accent)on hover/focus..menu-toggler:position: relative; width: 3.5rem; height: 3.5rem; padding: 1.125rem;background-color: var(--pill-toggle); border-radius: 4rem;display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 0.2rem;flex-shrink: 0; cursor: pointer; transition: background-color .25s ease;— the lime square is the only saturated area of the closed pill, which is what makes it read as the handle..menu-toggler span:position: relative; width: 100%; height: 0.125rem; background-color: var(--ink);transition: transform .3s ease, background-color .25s ease; transform-origin: center; will-change: transform;(two thin black bars = the hamburger)..drift-mark: an inline decorative<svg>asterisk (three cobalt#1141ffstrokes)position: fixed; top:50%; right: clamp(2rem,8vw,9rem); transform: translateY(-50%); width: min(24rem,30vw); opacity: .22; pointer-events: none; z-index: 5;— a big anchor on the empty right half that gives the drag somewhere to go. It isaria-hidden, never animated, anddisplay:noneon mobile.- Mobile (
max-width: 768px): the pill moves totop/left: 1remand shrinks to2.9remtall (logo4.2rem, link text0.86rem, toggler padding0.95rem), and.menu-drop-zonefollows atcalc(2.9rem + 0.7rem + 2px).
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
Inter arrives as a web font 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 Studio, 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 Studio, 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.
Images
This component ships with 1 reference asset, 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/draggable-menu/logo.svg
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—--paper,--paper-lit,--paper-edge,--ink,--muted,--accent,--accent-lime,--pill-shell,--pill-toggle. 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
*,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.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two Draggable instances installed on the same pill fighting over the same pointer events, two click listeners on the toggler each flipping their own copy of isMenuOpen. The symptom is a drawer that stutters mid-drag or opens twice on one click, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The file's own last line, mount(Object.assign({}, DEFAULTS)), runs the instant the module is evaluated — no DOMContentLoaded, no readiness check, nothing to wait for. That "instant the module is evaluated" is import time in React, before the component has rendered .menu-drawer or anything else mount() looks for. Move the entire body of mount() — the width measurements, the three gsap.set calls, the font-ready listener, toggleMenu's wiring, and the Draggable.create call — into a useEffect with an empty dependency array, and return the function mount() already builds as destroy. The mount(config) / destroy() pair is not scaffolding to strip out here; it already has the shape useEffect wants.
*(2) Element lookups* — .menu-drop-zone and .menu-drawer are siblings, not container and content, so the root ref has to wrap both, not just the drawer. Scope all six lookups this component makes — drop zone, drawer, logo, items row, the .menu-item list, and the toggler — to that ref. .menu-drop-zone is the one easiest to get wrong: it sits at opacity: 0 and reads as decorative, but it is a live target the drag callbacks write to on every drag event, and an unscoped document.querySelector(".menu-drop-zone") will happily bind to the copy the StrictMode remount is discarding.
*(3) Cleanup* — Wrap the body of mount() in a gsap.context scoped to the root ref and revert it in the cleanup:
useEffect(() => {
let destroyed = false;
const ctx = gsap.context(() => {
// the three gsap.set(...) calls, then Draggable.create(menuDrawer, { ... })
}, rootRef);
return () => {
destroyed = true;
ctx.revert();
};
}, []);
That covers the Draggable instance itself: unlike a gsap.ticker subscription, Draggable does carry its own .revert() method, and because Draggable.create runs synchronously inside the factory above, ctx.revert() reaches it and tears it down correctly — there is no need to keep the array Draggable.create returns and call .kill() on it by hand the way the vanilla destroy() does; that bookkeeping exists there only because the vanilla version has no context to hand the instance to.
What ctx.revert() cannot reach is every tween created after that synchronous pass finishes: the row-width tween and the item pop tween inside openMenu/closeMenu, and the drop-zone opacity tween and the snap-back tween inside onDragStart/onDrag/onDragEnd. gsap.context only attributes a tween to itself while its factory function is synchronously running, and none of those four run during that window — openMenu/closeMenu fire later, from the toggler's click listener, and the three drag callbacks fire later still, called directly by Draggable in response to pointer events, bypassing anything gsap.context wraps. A component that unmounts mid-open, or mid-drag, leaves one of those tweens live with nothing tracking it. The vanilla destroy() already closes this gap without relying on the context at all: it keeps the literal list of elements any of those tweens can touch — items row, drawer, drop zone, and every .menu-item — and calls gsap.killTweensOf on that list, then gsap.set(..., { clearProps: "all" }). Port those two calls into the effect's cleanup, after ctx.revert(), along with removing the toggler's click listener and clearing its close class — the same three things destroy() already does.
*(4) The font-ready promise* — document.fonts.ready.then(...) can settle after a StrictMode unmount has already run. The script already guards for this with its own destroyed flag; reuse the same flag your cleanup sets instead of adding a second one. Keep the guard's other half too: if (destroyed || isMenuOpen) return; also bails out when the drawer has already been opened by the time the font resolves, so the re-measured width doesn't overwrite an in-progress opening tween. Losing that second condition wouldn't announce itself as a mounting bug — it would look like an occasional wrong-width drawer on a slow connection, which is a harder bug to trace back here.