34 CSS Floating Buttons05 / 34

Light JSMIT licensed

Accessible WAI-ARIA Floating Action Button (WCAG 2.2)

A FAB menu that passes an actual audit. It implements the ARIA menu-button pattern properly: roving tabindex, Arrow/Home/End navigation, Escape to close with focus restored, type-ahead by first letter, a live region announcing the chosen action, 44px targets, a focus ring that survives high-contrast mode, and no target obscured by the floating layer. Every rule here maps to a real failure I have watched people hit.

Published

Live Demo
Try it

The code

<section class="fb-05" aria-label="Accessible floating action button demo">
  <div class="fb-05__page">
    <p class="fb-05__eyebrow">menu button pattern · roving tabindex</p>
    <h2 class="fb-05__title">Tab to it. Then use the arrows.</h2>
    <p class="fb-05__sub">Enter or Space or Arrow&nbsp;Up opens the menu and focuses the first item. Arrows move, Home/End jump, first letter type-ahead works, Escape closes and returns focus to the button.</p>
    <ul class="fb-05__keys">
      <li><kbd>Tab</kbd> reach the button</li><li><kbd>↑</kbd><kbd>↓</kbd> move</li><li><kbd>Home</kbd><kbd>End</kbd> jump</li><li><kbd>Esc</kbd> close</li>
    </ul>
    <p class="fb-05__live" id="fb-05-live" role="status" aria-live="polite">No action taken yet.</p>
  </div>
  <div class="fb-05__scrim" id="fb-05-scrim" hidden></div>
  <div class="fb-05__dock">
    <ul class="fb-05__menu" id="fb-05-menu" role="menu" aria-label="Quick actions" hidden>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="New document"><span class="fb-05__iBox" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M6 3h8l4 4v14H6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M14 3v5h4" fill="none" stroke="currentColor" stroke-width="1.9"/></svg></span>New document</button></li>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="Upload file"><span class="fb-05__iBox" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 19h14" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg></span>Upload file</button></li>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="Invite teammate"><span class="fb-05__iBox" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><circle cx="10" cy="8" r="3.4" fill="none" stroke="currentColor" stroke-width="1.9"/><path d="M4 19c0-3.3 2.7-5 6-5s6 1.7 6 5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M19 8v6M16 11h6" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg></span>Invite teammate</button></li>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="Start recording"><span class="fb-05__iBox" aria-hidden="true"><circle cx="12" cy="12" r="5" fill="currentColor"/><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/></svg></span>Start recording</button></li>
    </ul>
    <button class="fb-05__fab" id="fb-05-fab" type="button" aria-expanded="false" aria-haspopup="true" aria-controls="fb-05-menu" aria-label="Quick actions">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
    </button>
  </div>
</section>
.fb-05 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-05-bg);
  --fb-05-bg: oklch(0.98 0.008 250);
  --fb-05-brand: oklch(0.48 0.19 262);
  --fb-05-brandDeep: oklch(0.38 0.17 262);
  --fb-05-ink: oklch(0.21 0.02 260);
  --fb-05-mut: oklch(0.5 0.02 260);
  --fb-05-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-05-ink);
  position: relative;
  display: grid;
  place-items: center;
  padding: clamp(20px,5vw,56px);
  overflow: hidden;
}

.fb-05 *,
.fb-05 *::before,
.fb-05 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.fb-05__page {
  width: min(100%,58ch);
  display: grid;
  gap: 13px;
  justify-items: center;
  text-align: center;
  z-index: 1;
}

.fb-05__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--fb-05-brand);
}

.fb-05__title {
  font-size: clamp(26px,4.6vw,44px);
  line-height: 1.05;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-05__sub {
  font-size: clamp(13.5px,1.7vw,16.5px);
  line-height: 1.62;
  color: var(--fb-05-mut);
  text-wrap: pretty;
}

.fb-05__keys {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  font-size: 12px;
  color: var(--fb-05-mut);
}

.fb-05__keys li {
  display: flex;
  align-items: center;
  gap: 5px;
  background: var(--fb-05-card);
  border: 1px solid oklch(0.21 0.02 260/.12);
  border-radius: 99px;
  padding: 6px 12px;
}

.fb-05 kbd {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  background: oklch(0.94 0.008 260);
  border: 1px solid oklch(0.21 0.02 260/.16);
  border-bottom-width: 2px;
  border-radius: 5px;
  padding: 1px 5px;
  color: var(--fb-05-ink);
}

.fb-05__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-05-brandDeep);
  background: oklch(0.48 0.19 262/.08);
  border: 1px dashed oklch(0.48 0.19 262/.35);
  border-radius: 12px;
  padding: 9px 15px;
  min-height: 36px;
  display: grid;
  place-items: center;
}

.fb-05__scrim {
  position: absolute;
  inset: 0;
  z-index: 4;
  background: oklch(0.21 0.02 260/.42);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  animation: fb-05-fade .2s ease;
}

.fb-05__scrim[hidden] {
  display: none;
}

@keyframes fb-05-fade {
  from {
    opacity: 0;
  }
}

.fb-05__dock {
  position: absolute;
  right: clamp(16px,3vw,32px);
  bottom: calc(clamp(16px,3vw,32px) + env(safe-area-inset-bottom));
  z-index: 5;
  display: grid;
  justify-items: end;
  gap: 14px;
}

.fb-05__menu {
  list-style: none;
  display: grid;
  gap: 9px;
  justify-items: end;
}

.fb-05__menu[hidden] {
  display: none;
}

.fb-05__item {
  display: flex;
  align-items: center;
  gap: 11px;
  min-height: 48px;
  padding: 0 18px 0 10px;
  border: 1px solid oklch(0.21 0.02 260/.1);
  border-radius: 99px;
  background: var(--fb-05-card);
  color: var(--fb-05-ink);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 10px 26px -14px oklch(0.21 0.02 260/.7);
  animation: fb-05-rise .26s cubic-bezier(.2,.9,.3,1.1) backwards;
  transition: transform .18s ease,background .18s ease;
}

.fb-05__menu li:nth-child(1) .fb-05__item {
  animation-delay: .02s;
}

.fb-05__menu li:nth-child(2) .fb-05__item {
  animation-delay: .06s;
}

.fb-05__menu li:nth-child(3) .fb-05__item {
  animation-delay: .1s;
}

.fb-05__menu li:nth-child(4) .fb-05__item {
  animation-delay: .14s;
}

@keyframes fb-05-rise {
  from {
    opacity: 0;
    transform: translateY(14px) scale(.94);
  }
}

.fb-05__item:hover {
  background: oklch(0.96 0.01 262);
  transform: translateX(-2px);
}

.fb-05__item:focus-visible,
.fb-05__fab:focus-visible {
  outline: 3px solid var(--fb-05-brandDeep);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px oklch(1 0 0/.9),0 10px 26px -14px oklch(0.21 0.02 260/.7);
}

.fb-05__iBox {
  flex: none;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: oklch(0.48 0.19 262/.1);
  color: var(--fb-05-brandDeep);
}

.fb-05__iBox svg {
  width: 20px;
  height: 20px;
}

.fb-05__fab {
  display: grid;
  place-items: center;
  inline-size: 60px;
  block-size: 60px;
  border: 0;
  border-radius: 20px;
  cursor: pointer;
  color: oklch(0.99 0.01 262);
  background: linear-gradient(150deg,oklch(0.56 0.2 268),var(--fb-05-brandDeep));
  box-shadow: 0 12px 30px -10px oklch(0.38 0.17 262/.8),0 2px 6px oklch(0.3 0.1 262/.28);
  transition: transform .28s cubic-bezier(.2,1.3,.4,1),border-radius .28s ease;
}

.fb-05__fab svg {
  width: 26px;
  height: 26px;
  transition: rotate .3s cubic-bezier(.3,1.4,.4,1);
}

.fb-05__fab:hover {
  transform: translateY(-2px);
}

.fb-05__fab:active {
  transform: scale(.94);
}

.fb-05__fab[aria-expanded="true"] {
  border-radius: 50%;
}

.fb-05__fab[aria-expanded="true"] svg {
  rotate: 135deg;
}

@media (forced-colors: active) {
  .fb-05__item,
    .fb-05__fab {
    border: 1px solid ButtonText;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fb-05 * {
    animation: none !important;
    transition: none !important;
  }
}
(() => {
  const fab = document.getElementById('fb-05-fab');
  const menu = document.getElementById('fb-05-menu');
  const scrim = document.getElementById('fb-05-scrim');
  const live = document.getElementById('fb-05-live');
  if (!fab || !menu || fab.dataset.fbWired) return;
  fab.dataset.fbWired = '1';
  const items = [...menu.querySelectorAll('.fb-05__item')];
  let active = 0;
  const focusAt = (i) => {
    active = (i + items.length) % items.length;
    items.forEach((el, n) => { el.tabIndex = n === active ? 0 : -1; });
    items[active].focus();
  };
  const open = (fromEnd) => {
    fab.setAttribute('aria-expanded', 'true');
    menu.hidden = false; scrim.hidden = false;
    focusAt(fromEnd ? items.length - 1 : 0);
  };
  const close = (restore) => {
    fab.setAttribute('aria-expanded', 'false');
    menu.hidden = true; scrim.hidden = true;
    if (restore) fab.focus();
  };
  const isOpen = () => fab.getAttribute('aria-expanded') === 'true';
  fab.addEventListener('click', () => (isOpen() ? close(false) : open(false)));
  fab.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowUp') { e.preventDefault(); open(true); }
    if (e.key === 'ArrowDown') { e.preventDefault(); open(false); }
  });
  scrim.addEventListener('click', () => close(true));
  menu.addEventListener('keydown', (e) => {
    const k = e.key;
    if (k === 'ArrowDown') { e.preventDefault(); focusAt(active + 1); }
    else if (k === 'ArrowUp') { e.preventDefault(); focusAt(active - 1); }
    else if (k === 'Home') { e.preventDefault(); focusAt(0); }
    else if (k === 'End') { e.preventDefault(); focusAt(items.length - 1); }
    else if (k === 'Escape') { e.preventDefault(); close(true); }
    else if (k === 'Tab') { close(false); }
    else if (k.length === 1 && /\S/.test(k)) {
      const from = active + 1;
      const found = items.findIndex((el, n) => n >= from && el.textContent.trim().toLowerCase().startsWith(k.toLowerCase()));
      const wrap = found > -1 ? found : items.findIndex((el) => el.textContent.trim().toLowerCase().startsWith(k.toLowerCase()));
      if (wrap > -1) focusAt(wrap);
    }
  });
  items.forEach((el) => el.addEventListener('click', () => {
    live.textContent = el.dataset.fbAct + ' — action fired at ' + new Date().toLocaleTimeString();
    close(true);
  }));
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Floating Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Accessible WAI-ARIA Floating Action Button (WCAG 2.2)
Source: https://codefronts.com/components/css-floating-buttons/accessible-wai-aria-floating-action-button-wcag-2-2/

A FAB menu that passes an actual audit. It implements the ARIA menu-button pattern properly: roving tabindex, Arrow/Home/End navigation, Escape to close with focus restored, type-ahead by first letter, a live region announcing the chosen action, 44px targets, a focus ring that survives high-contrast mode, and no target obscured by the floating layer. Every rule here maps to a real failure I have watched people hit.
## HTML
```html
<section class="fb-05" aria-label="Accessible floating action button demo">
  <div class="fb-05__page">
    <p class="fb-05__eyebrow">menu button pattern · roving tabindex</p>
    <h2 class="fb-05__title">Tab to it. Then use the arrows.</h2>
    <p class="fb-05__sub">Enter or Space or Arrow&nbsp;Up opens the menu and focuses the first item. Arrows move, Home/End jump, first letter type-ahead works, Escape closes and returns focus to the button.</p>
    <ul class="fb-05__keys">
      <li><kbd>Tab</kbd> reach the button</li><li><kbd>↑</kbd><kbd>↓</kbd> move</li><li><kbd>Home</kbd><kbd>End</kbd> jump</li><li><kbd>Esc</kbd> close</li>
    </ul>
    <p class="fb-05__live" id="fb-05-live" role="status" aria-live="polite">No action taken yet.</p>
  </div>
  <div class="fb-05__scrim" id="fb-05-scrim" hidden></div>
  <div class="fb-05__dock">
    <ul class="fb-05__menu" id="fb-05-menu" role="menu" aria-label="Quick actions" hidden>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="New document"><span class="fb-05__iBox" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M6 3h8l4 4v14H6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M14 3v5h4" fill="none" stroke="currentColor" stroke-width="1.9"/></svg></span>New document</button></li>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="Upload file"><span class="fb-05__iBox" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M12 17V6M8 10l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 19h14" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg></span>Upload file</button></li>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="Invite teammate"><span class="fb-05__iBox" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><circle cx="10" cy="8" r="3.4" fill="none" stroke="currentColor" stroke-width="1.9"/><path d="M4 19c0-3.3 2.7-5 6-5s6 1.7 6 5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M19 8v6M16 11h6" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg></span>Invite teammate</button></li>
      <li role="none"><button class="fb-05__item" role="menuitem" type="button" tabindex="-1" data-fb-act="Start recording"><span class="fb-05__iBox" aria-hidden="true"><circle cx="12" cy="12" r="5" fill="currentColor"/><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/></svg></span>Start recording</button></li>
    </ul>
    <button class="fb-05__fab" id="fb-05-fab" type="button" aria-expanded="false" aria-haspopup="true" aria-controls="fb-05-menu" aria-label="Quick actions">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
    </button>
  </div>
</section>
```
## CSS
```css
.fb-05 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--fb-05-bg);
  --fb-05-bg: oklch(0.98 0.008 250);
  --fb-05-brand: oklch(0.48 0.19 262);
  --fb-05-brandDeep: oklch(0.38 0.17 262);
  --fb-05-ink: oklch(0.21 0.02 260);
  --fb-05-mut: oklch(0.5 0.02 260);
  --fb-05-card: oklch(1 0 0);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--fb-05-ink);
  position: relative;
  display: grid;
  place-items: center;
  padding: clamp(20px,5vw,56px);
  overflow: hidden;
}

.fb-05 *,
.fb-05 *::before,
.fb-05 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.fb-05__page {
  width: min(100%,58ch);
  display: grid;
  gap: 13px;
  justify-items: center;
  text-align: center;
  z-index: 1;
}

.fb-05__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--fb-05-brand);
}

.fb-05__title {
  font-size: clamp(26px,4.6vw,44px);
  line-height: 1.05;
  letter-spacing: -.03em;
  font-weight: 700;
  text-wrap: balance;
}

.fb-05__sub {
  font-size: clamp(13.5px,1.7vw,16.5px);
  line-height: 1.62;
  color: var(--fb-05-mut);
  text-wrap: pretty;
}

.fb-05__keys {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  font-size: 12px;
  color: var(--fb-05-mut);
}

.fb-05__keys li {
  display: flex;
  align-items: center;
  gap: 5px;
  background: var(--fb-05-card);
  border: 1px solid oklch(0.21 0.02 260/.12);
  border-radius: 99px;
  padding: 6px 12px;
}

.fb-05 kbd {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  background: oklch(0.94 0.008 260);
  border: 1px solid oklch(0.21 0.02 260/.16);
  border-bottom-width: 2px;
  border-radius: 5px;
  padding: 1px 5px;
  color: var(--fb-05-ink);
}

.fb-05__live {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  color: var(--fb-05-brandDeep);
  background: oklch(0.48 0.19 262/.08);
  border: 1px dashed oklch(0.48 0.19 262/.35);
  border-radius: 12px;
  padding: 9px 15px;
  min-height: 36px;
  display: grid;
  place-items: center;
}

.fb-05__scrim {
  position: absolute;
  inset: 0;
  z-index: 4;
  background: oklch(0.21 0.02 260/.42);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  animation: fb-05-fade .2s ease;
}

.fb-05__scrim[hidden] {
  display: none;
}

@keyframes fb-05-fade {
  from {
    opacity: 0;
  }
}

.fb-05__dock {
  position: absolute;
  right: clamp(16px,3vw,32px);
  bottom: calc(clamp(16px,3vw,32px) + env(safe-area-inset-bottom));
  z-index: 5;
  display: grid;
  justify-items: end;
  gap: 14px;
}

.fb-05__menu {
  list-style: none;
  display: grid;
  gap: 9px;
  justify-items: end;
}

.fb-05__menu[hidden] {
  display: none;
}

.fb-05__item {
  display: flex;
  align-items: center;
  gap: 11px;
  min-height: 48px;
  padding: 0 18px 0 10px;
  border: 1px solid oklch(0.21 0.02 260/.1);
  border-radius: 99px;
  background: var(--fb-05-card);
  color: var(--fb-05-ink);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 10px 26px -14px oklch(0.21 0.02 260/.7);
  animation: fb-05-rise .26s cubic-bezier(.2,.9,.3,1.1) backwards;
  transition: transform .18s ease,background .18s ease;
}

.fb-05__menu li:nth-child(1) .fb-05__item {
  animation-delay: .02s;
}

.fb-05__menu li:nth-child(2) .fb-05__item {
  animation-delay: .06s;
}

.fb-05__menu li:nth-child(3) .fb-05__item {
  animation-delay: .1s;
}

.fb-05__menu li:nth-child(4) .fb-05__item {
  animation-delay: .14s;
}

@keyframes fb-05-rise {
  from {
    opacity: 0;
    transform: translateY(14px) scale(.94);
  }
}

.fb-05__item:hover {
  background: oklch(0.96 0.01 262);
  transform: translateX(-2px);
}

.fb-05__item:focus-visible,
.fb-05__fab:focus-visible {
  outline: 3px solid var(--fb-05-brandDeep);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px oklch(1 0 0/.9),0 10px 26px -14px oklch(0.21 0.02 260/.7);
}

.fb-05__iBox {
  flex: none;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: oklch(0.48 0.19 262/.1);
  color: var(--fb-05-brandDeep);
}

.fb-05__iBox svg {
  width: 20px;
  height: 20px;
}

.fb-05__fab {
  display: grid;
  place-items: center;
  inline-size: 60px;
  block-size: 60px;
  border: 0;
  border-radius: 20px;
  cursor: pointer;
  color: oklch(0.99 0.01 262);
  background: linear-gradient(150deg,oklch(0.56 0.2 268),var(--fb-05-brandDeep));
  box-shadow: 0 12px 30px -10px oklch(0.38 0.17 262/.8),0 2px 6px oklch(0.3 0.1 262/.28);
  transition: transform .28s cubic-bezier(.2,1.3,.4,1),border-radius .28s ease;
}

.fb-05__fab svg {
  width: 26px;
  height: 26px;
  transition: rotate .3s cubic-bezier(.3,1.4,.4,1);
}

.fb-05__fab:hover {
  transform: translateY(-2px);
}

.fb-05__fab:active {
  transform: scale(.94);
}

.fb-05__fab[aria-expanded="true"] {
  border-radius: 50%;
}

.fb-05__fab[aria-expanded="true"] svg {
  rotate: 135deg;
}

@media (forced-colors: active) {
  .fb-05__item,
    .fb-05__fab {
    border: 1px solid ButtonText;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fb-05 * {
    animation: none !important;
    transition: none !important;
  }
}
```

## JavaScript
```js
(() => {
  const fab = document.getElementById('fb-05-fab');
  const menu = document.getElementById('fb-05-menu');
  const scrim = document.getElementById('fb-05-scrim');
  const live = document.getElementById('fb-05-live');
  if (!fab || !menu || fab.dataset.fbWired) return;
  fab.dataset.fbWired = '1';
  const items = [...menu.querySelectorAll('.fb-05__item')];
  let active = 0;
  const focusAt = (i) => {
    active = (i + items.length) % items.length;
    items.forEach((el, n) => { el.tabIndex = n === active ? 0 : -1; });
    items[active].focus();
  };
  const open = (fromEnd) => {
    fab.setAttribute('aria-expanded', 'true');
    menu.hidden = false; scrim.hidden = false;
    focusAt(fromEnd ? items.length - 1 : 0);
  };
  const close = (restore) => {
    fab.setAttribute('aria-expanded', 'false');
    menu.hidden = true; scrim.hidden = true;
    if (restore) fab.focus();
  };
  const isOpen = () => fab.getAttribute('aria-expanded') === 'true';
  fab.addEventListener('click', () => (isOpen() ? close(false) : open(false)));
  fab.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowUp') { e.preventDefault(); open(true); }
    if (e.key === 'ArrowDown') { e.preventDefault(); open(false); }
  });
  scrim.addEventListener('click', () => close(true));
  menu.addEventListener('keydown', (e) => {
    const k = e.key;
    if (k === 'ArrowDown') { e.preventDefault(); focusAt(active + 1); }
    else if (k === 'ArrowUp') { e.preventDefault(); focusAt(active - 1); }
    else if (k === 'Home') { e.preventDefault(); focusAt(0); }
    else if (k === 'End') { e.preventDefault(); focusAt(items.length - 1); }
    else if (k === 'Escape') { e.preventDefault(); close(true); }
    else if (k === 'Tab') { close(false); }
    else if (k.length === 1 && /\S/.test(k)) {
      const from = active + 1;
      const found = items.findIndex((el, n) => n >= from && el.textContent.trim().toLowerCase().startsWith(k.toLowerCase()));
      const wrap = found > -1 ? found : items.findIndex((el) => el.textContent.trim().toLowerCase().startsWith(k.toLowerCase()));
      if (wrap > -1) focusAt(wrap);
    }
  });
  items.forEach((el) => el.addEventListener('click', () => {
    live.textContent = el.dataset.fbAct + ' — action fired at ' + new Date().toLocaleTimeString();
    close(true);
  }));
})();
```

How this works

The button owns the state; the menu is a real role="menu" with role="menuitem" children and exactly one tabbable item at a time:

items.forEach((el,i) => el.tabIndex = i === active ? 0 : -1);
items[active].focus();
// ArrowDown/Up wrap, Home/End jump, Escape closes and restores focus
// printable key → jump to next item whose label starts with it

Roving tabindex, not tabindex="0" on everything: a menu should be one Tab stop, and arrows should move within it. That is what assistive-tech users expect from a menu, and it keeps a 6-item FAB from adding 6 stops to the page's tab order.

Focus visibility is handled with a two-colour ring — outline:3px solid plus a contrasting box-shadow spread — so the indicator remains visible on both the dark scrim and the light menu, and because it is an outline, it is preserved in forced-colors mode where box-shadows are dropped.

The scrim is inert-adjacent: it captures clicks to close, but the menu itself is placed before it in the DOM order so screen-reader reading order matches visual order. The result of an action is announced through an aria-live="polite" region rather than a toast that vanishes before it can be read.

Make it yours

  • Menu direction: --fb-05-dir flips the stack upward or downward; keyboard order always follows DOM order, so nothing else changes.
  • Descriptions: add a second line per item and reference it with aria-describedby if actions need explanation beyond the label.
  • Confirmation: destructive items can set aria-haspopup="dialog" and open a confirm dialog rather than firing immediately.
  • Shortcut key: bind a global key (for example ".") to focus the FAB, and announce it in the button's aria-keyshortcuts attribute.

Gotchas — read before shipping

  • A floating button that covers a form control or link fails the "not obscured" requirement. Reserve space at the bottom of scrollable content, or move the FAB when a keyboard opens.
  • Removing the outline and relying on a colour change is invisible in forced-colors mode. Keep a real outline, add colour on top.
  • Icon-only items need visible text or an aria-label whose words match the visible tooltip — mismatched names break voice control ("click Add photo" must work).
  • Do not trap focus in a non-modal menu; Tab should close it and continue through the page. Only modal dialogs trap.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

Pure DOM APIs and standard ARIA — works in every modern browser and with NVDA, JAWS, VoiceOver and TalkBack. :focus-visible is supported since Chrome 86 / Safari 15.4 / Firefox 85.

Techniques used in this demo

Search CodeFronts

Loading…