Lit web component · headless · accessible

<flying-menu>

A floating menu the user can drag into any screen corner. The popup opens from that corner with the right spacing — for any button or menu size. You bring two slots; the component owns the behaviour, not the look.

View on GitHub

Try it: drag the round button to a corner, then tap it. Resize the window — it stays in its corner.

Install

npm install @igor-ganov/flying-menu

lit is pulled in automatically — nothing else to install.

Drag & snap

Drag the trigger anywhere; it snaps to the nearest corner and remembers it.

Edge-aware popup

The menu anchors to the trigger's corner and stays inside the viewport — computed from measured geometry.

Headless

No imposed colours, borders or sizes. Style your slots and the ::part() wrappers.

Accessible

ARIA on your control, focus management, Esc, outside-click, reduced-motion.

Usage

Simplest

Headless: bring any trigger and any menu, the component does the rest.

<flying-menu>
  <button slot="trigger" aria-label="Open menu">☰</button>
  <nav slot="menu" aria-label="Primary">
    <a href="/home">Home</a>
    <a href="/docs">Docs</a>
  </nav>
</flying-menu>

<script type="module">import '@igor-ganov/flying-menu'</script>

Move the trigger to a corner (then open it with the trigger to see the popup re-anchor):

This page's floating button — full recipe

Everything behind the button you're looking at: the gradient FAB, the hamburger → ✕ morph and the styled popup. Drop in the HTML, CSS and TS — no other dependencies.

HTML

<flying-menu corner="bottom-right">
  <button slot="trigger" class="fab" aria-label="Open navigation">
    <span class="fab-bar"></span>
    <span class="fab-bar"></span>
    <span class="fab-bar"></span>
  </button>
  <nav slot="menu" class="popup" aria-label="Navigation">
    <a href="#home">Home</a>
    <a href="#docs">Docs</a>
    <a href="#about">About</a>
  </nav>
</flying-menu>

CSS

flying-menu { --flying-menu-transition: 200ms cubic-bezier(0.2, 0.8, 0.2, 1); }

/* Round gradient FAB */
.fab {
  position: relative;
  width: 56px; height: 56px;
  border: none; border-radius: 50%;
  background: linear-gradient(135deg, #5b8cff, #8b5cf6);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.45);
  cursor: pointer; padding: 0;
}
.fab:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }

/* Hamburger ↔ X (three centre-anchored bars). Each bar keeps its resting and
   open transform together; `flying-menu[open] &` nests the host's open state. */
.fab-bar {
  position: absolute; top: 50%; left: 50%;
  width: 24px; height: 2px; margin: -1px 0 0 -12px;
  border-radius: 1px; background: #fff;
  transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 160ms ease;

  &:nth-child(1) {
    transform: translateY(-7px) rotate(0);
    flying-menu[open] & { transform: translateY(0) rotate(45deg); }
  }
  &:nth-child(2) {
    transform: rotate(0);
    flying-menu[open] & { opacity: 0; transform: rotate(45deg); }
  }
  &:nth-child(3) {
    transform: translateY(7px) rotate(0);
    flying-menu[open] & { transform: translateY(0) rotate(-45deg); }
  }

  @media (prefers-reduced-motion: reduce) { transition: none; }
}

/* Popup (styled via the ::part hook) */
flying-menu::part(menu) {
  background: #161b26;
  border: 1px solid #2b3242;
  border-radius: 14px;
  padding: 0.5rem;
  box-shadow: 0 16px 40px rgb(0 0 0 / 0.55);
  min-width: 13rem;
}
.popup { display: flex; flex-direction: column; }
.popup a {
  color: #e6e9ef; text-decoration: none;
  padding: 0.6rem 0.8rem; border-radius: 9px;
}
.popup a:hover, .popup a:focus-visible {
  background: #1e2533;
  outline: 2px solid #5b8cff; outline-offset: -2px;
}

TypeScript

import '@igor-ganov/flying-menu'