Basic Floating Action Button
A round, fixed icon button for the one action a screen is really about - with a ring that survives any backdrop.
6 frameworksBeginner
A FAB that expands into a stack of actions - the disclosure pattern, with Escape and focus return wired up.
<!--
The disclosure pattern, not a menu: aria-expanded on the trigger and
aria-controls pointing at the panel it opens. The actions are a plain list of
buttons - they are commands, each carrying its own aria-label, and each one is
reachable by Tab because they are real buttons rather than divs.
-->
<div class="dial">
<ul class="dial__actions" id="dial-actions" hidden>
<li>
<button class="dial__action" type="button" aria-label="New note" data-id="note">
<svg class="dial__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M4 4h16v12l-4 4H4z" />
</svg>
</button>
</li>
<li>
<button class="dial__action" type="button" aria-label="Upload file" data-id="upload">
<svg class="dial__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
</li>
<li>
<button class="dial__action" type="button" aria-label="Invite teammate" data-id="invite">
<svg class="dial__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M16 19v-2a4 4 0 0 0-8 0v2M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
</svg>
</button>
</li>
</ul>
<button
class="dial__trigger"
type="button"
aria-expanded="false"
aria-controls="dial-actions"
aria-label="Open quick actions"
>
<svg class="dial__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<script>
document.querySelectorAll('.dial').forEach(function (root) {
var trigger = root.querySelector('.dial__trigger');
var panel = root.querySelector('.dial__actions');
function setOpen(open) {
trigger.setAttribute('aria-expanded', String(open));
trigger.setAttribute('aria-label', open ? 'Close quick actions' : 'Open quick actions');
panel.hidden = !open;
if (open) {
// Move focus into the stack so the keyboard lands where the eye does.
var first = panel.querySelector('.dial__action');
if (first) first.focus();
}
}
trigger.addEventListener('click', function () {
setOpen(trigger.getAttribute('aria-expanded') !== 'true');
});
// Escape closes and hands focus BACK to the trigger - never leave a
// keyboard user stranded on a button that just disappeared.
root.addEventListener('keydown', function (event) {
if (event.key !== 'Escape') return;
if (trigger.getAttribute('aria-expanded') !== 'true') return;
setOpen(false);
trigger.focus();
});
// Clicking away collapses, but focus stays where the user put it.
document.addEventListener('click', function (event) {
if (root.contains(event.target)) return;
if (trigger.getAttribute('aria-expanded') !== 'true') return;
setOpen(false);
});
panel.addEventListener('click', function (event) {
var action = event.target.closest('.dial__action');
if (!action) return;
setOpen(false);
trigger.focus();
console.log('speed dial action:', action.dataset.id);
});
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | SpeedDialAction[] | - | The array of items to render. |
ariaLabel | string | 'Quick actions' | The button's accessible name. Required for icon-only buttons. |
onSelect | (id: string) => void | - | Fired with the menu item the user chose. |
className | string | - | Additional classes merged onto the root element. |
This is a **disclosure**, not a `role="menu"`. `aria-expanded` on the trigger and `aria-controls` pointing at the `<ul>` is the entire contract, and the plus rotating into a cross hangs off that same `aria-expanded` - so the icon physically cannot point the wrong way. The three things half-built speed dials skip are all here: opening moves focus into the first action (otherwise the keyboard is still on the trigger while the eye is on a stack that appeared somewhere else); Escape collapses it; and closing returns focus to the trigger, because the button focus *was* on has just been removed from the document and focus falls to `<body>` if you do not catch it. Each action is a real `<button>` with its own `aria-label` - they are icon-only, so each one needs its own name, not a shared one. The staggered entrance sits behind `motion-safe:`/`prefers-reduced-motion` and the reduced fallback lands on the *end* state - opacity 1, no transform - so a reduced-motion user gets the stack instantly rather than frozen half-faded. Swap `DEFAULT_ACTIONS` for yours; three to five is the range where a dial beats a menu.