34 CSS Floating Buttons
05 / 34

All 34 in 34 CSS Floating Buttons

  1. 01Material Design 3 Floating Action Button (FAB)
  2. 02iOS-Style Compose Floating Button
  3. 03WhatsApp Floating Message Button CSS
  4. 04Intercom / Drift Chat Bubble Floating Button
  5. 05Accessible WAI-ARIA Floating Action Button (WCAG 2.2)
  6. 06Extended FAB with Icon and Label (Material Design 3)
  7. 07Speed Dial Radial Menu FAB with Backdrop
  8. 08Pure CSS FAB Speed Dial (No JavaScript)
  9. 09Material Gradient Floating Action Button CSS
  10. 10Vertical and Horizontal Speed Dial FAB Menu CSS
  11. 11Morphing FAB Button That Expands to Pill CSS
  12. 12Scroll-to-Top Floating Button with SVG Progress Ring
  13. 13Floating Chat Widget FAB with Notification Panel
  14. 14Fan and Arc Radial Spread FAB Menu CSS
  15. 15Apple Squircle Superellipse Floating Button CSS
  16. 16Social Share Floating Button Cluster CSS
  17. 17Glassmorphism Floating Action Button with Backdrop Blur
  18. 18Cyberpunk Neon Glow Floating Button CSS
  19. 19Floating Compose Button with Tooltip on Hover CSS
  20. 20Draggable Floating Action Button with Corner Snap CSS
  21. 21Notification Bell Floating Button with Badge Counter
  22. 22Floating Cart Button with Live Badge Counter CSS
  23. 23Magnetic Cursor Follower Floating Button CSS
  24. 24Dark Mode Theme Toggle Floating Button CSS
  25. 25Classic Gradient Circle Plus FAB CSS
  26. 26Pulse Ring Attention-Getter Floating Button CSS
  27. 27Pill-Shaped FAB with Icon and Text Label CSS
  28. 28Neo-Brutalist Hard Shadow Floating Button CSS
  29. 29Floating Button with Loading Spinner State CSS
  30. 30Draggable Floating Button with 6-Dot Grip Handle CSS
  31. 31Voice Recording Microphone Floating Button CSS
  32. 32Aurora Gradient Drift Premium Floating Button CSS
  33. 33Quick Emoji Reply Reaction Floating Button CSS
  34. 34Modern Squircle Rounded Square FAB CSS
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.

Live Demo
Try it

Nucleo: 44,000+ SVG icons in five families · All Icons Bundle $149

Nucleo · Affiliate link

Get the Nucleo bundle

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…