Drag & snap
Drag the trigger anywhere; it snaps to the nearest corner and remembers it.
Lit web component · headless · accessible
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.
Try it: drag the round button to a corner, then tap it. Resize the window — it stays in its corner.
npm install @igor-ganov/flying-menu
pnpm add @igor-ganov/flying-menu
yarn add @igor-ganov/flying-menu
bun add @igor-ganov/flying-menu
lit is pulled in automatically — nothing else to install.
Drag the trigger anywhere; it snaps to the nearest corner and remembers it.
The menu anchors to the trigger's corner and stays inside the viewport — computed from measured geometry.
No imposed colours, borders or sizes. Style your slots and the ::part() wrappers.
ARIA on your control, focus management, Esc, outside-click, reduced-motion.
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):
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'