The floating action button is the corner circle every app promotes its best action with — Gmail's compose, Maps' add-a-place, almost every Android screen's bottom-right button. It looks like a bespoke component. It isn't. It's a plain <button> taken out of the flow, anchored to a corner, with one boolean of state. I rebuilt it — FAB, speed-dial, morph, scrim, a11y — in one file. Here's how.
The FAB is just an anchored button
Not a widget: a <button> pulled out of the document flow with position:absolute (or fixed for a whole page) and pinned to a corner. Give it a comfortable 56px tap size, a rounded shape and a lift-off shadow so it reads as floating. Wrap it in a positioned container so the speed-dial items can stack against the same corner.
<div class="fab-wrap pos-br"> <!-- anchored bottom-right -->
<div class="sd-actions" role="menu"></div>
<button class="fab" aria-haspopup="menu" aria-expanded="false"><svg>…plus…</svg></button>
</div>
The plus morphs into a close — no icon swap
Open and closed must look unmistakably different, and the cheapest, most delightful way is to reuse the same glyph. A plus rotated 45° is already an ×; rotate a touch further for momentum. Keep one + SVG and toggle a class that spins it with a spring easing. No second asset, no swap — one glyph, one transition.
.fab-icon { transition: transform .3s cubic-bezier(.2,.8,.2,1.4); }
.fab.open .fab-icon { transform: rotate(135deg); } /* + -> x */
One boolean drives everything
All the state is a single open flag, and one setOpen(v) is the only place that mutates it: it toggles the .open class (CSS animates the rest), writes aria-expanded so assistive tech is always correct, shows or hides the scrim, and moves focus. Every trigger — a FAB click, a scrim tap, Escape, a menu item — just calls setOpen. Keep mutation in one function and the component stays honest.
function setOpen(v, focusItem){
open = v;
fab.classList.toggle('open', v);
fab.setAttribute('aria-expanded', String(v)); // a11y truth
scrim.classList.toggle('show', v);
stagger(v);
if (v && focusItem) items()[0].focus();
else if (!v) fab.focus(); // return focus
}
Stagger the reveal so it feels crafted
All items appearing at once feels cheap; a cascade feels made. The .open class fades and springs each .sd-item from a small offset — the CSS does the motion. To sequence them, set each item's transition-delay in JS: on the way out, the item nearest the FAB goes first; on the way back, reverse it so the closest row is last to leave. Frame-independent, pure CSS transition, JS only picks the delays.
function stagger(opening){
const list = items(); // DOM order: top -> bottom
list.forEach((el, i) => {
const order = opening ? (list.length - 1 - i) : i; // near-FAB first out
el.style.transitionDelay = (order * cfg.stagger) + 'ms';
});
}
The scrim does two jobs at once
When the dial opens, a translucent scrim covers the screen. It dims the busy content so the choices stand out, and it becomes one big dismiss target — a tap anywhere that isn't an action closes the menu. Fade it with the same open state and give it pointer-events:none when hidden so it never blocks the app underneath. Escape is the keyboard equivalent.
The a11y is what makes it real
It only counts as a menu if the keyboard agrees. The FAB carries aria-haspopup="menu" and aria-expanded; the container is role="menu", each item a role="menuitem" starting at tabindex="-1" (the roving pattern). On open, focus the first item; ↑/↓ roam with wraparound, Escape closes and returns focus to the FAB so the user is never dropped. And label every mini-button — an icon alone is a guess. Respect prefers-reduced-motion by collapsing the transitions.
The whole thing is one button and a list: a corner anchor, a rotate(135deg) morph, a data-driven set of labelled actions, one boolean, a staggered reveal, a scrim, two positions, an extended shape, and real keyboard focus. Try it — tap, move it left, dial the count, flip click vs hover:
Top comments (0)