DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A FAB is not a special widget — it is one plain button pinned to a corner, and one boolean opens the whole speed-dial

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>
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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';
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

https://dev48v.infy.uk/design/day51-fab.html

Top comments (0)