All components

Draggable Pill Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

Pill-shaped floating menu drawer that can be dragged anywhere in the viewport with GSAP Draggable (bounds: window); a dashed drop-zone outline fades in within a 200px snap threshold of its origin and the drawer springs back to its corner with power2.out on release. Clicking the hamburger toggler expands the pill: the items row animates from width 0 to full width (power3.inOut, 0.5s) while each link fades and scales in with a 0.05s stagger, reversed from the end on close.

How it's built

Categorymenu
Techgsap
GSAP pluginsDraggable
Complexitywidget
Performance costlight
Mobile-safeyes

draggable menu pill snap drop-zone hamburger stagger

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

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 whose width/marginRight are 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 clicktoggleMenu(): 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: window keeps the pill inside the viewport; cursor is grab at rest, grabbing while dragging.
  • this.x / this.y are the Draggable transform offsets relative to the home (top-left) position, so |x| < 200 && |y| < 200 means "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-zone ghost to opacity 1 when inside the 200px snap radius, 0 when outside — each a fast 0.1s tween. (The CSS also has a 0.2s opacity 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-zone ghost is pointer-events: none and opacity: 0 at 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; onDragStart re-sizes the ghost to the matching state, and the width re-measure on document.fonts.ready bails 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.