Building an Accessible React Floating Action Button (FAB) in React

Build an accessible React Floating Action Button (FAB) with portals, speed dial, theming, keyboard support, and tests—production-ready patterns included.

ASOasis
9 min read
Building an Accessible React Floating Action Button (FAB) in React

Image used for representation purposes only.

Overview

A floating action button (FAB) is a circular, high‑emphasis control that hovers above content to trigger the primary action on a screen—compose, create, add, record, or navigate. In React, a well‑built FAB should be visually clear, accessible by keyboard and assistive tech, unobtrusive on small screens, and easy to theme.

This guide walks through the UX principles, accessibility checklist, and a production‑ready implementation in React, including an optional “speed dial” (multi‑action) pattern, animations, theming, and tests.

When to use a FAB

Use a FAB when:

  • One action is clearly primary and benefits from persistent prominence.
  • The action is global to the current view and not contextual to a single item.
  • Screen real estate is limited and you need a compact, always‑available trigger.

Avoid a FAB when:

  • Multiple actions compete for the same priority (prefer a toolbar).
  • The action changes meaning across contexts.
  • It obscures critical content or navigation.

UX and accessibility checklist

  • Role: Use a semantic <button> (or an <a> with correct role when navigating).
  • Name: Provide an accessible name via aria-label if no visible text label is present.
  • Keyboard: The FAB must be focusable, operable with Enter/Space, and have a visible focus ring.
  • Contrast: Meet WCAG contrast guidelines for icon/label vs. background.
  • Hit target: 44×44 px minimum (56 px is standard on touch devices).
  • Placement: Bottom‑right (LTR) or bottom‑left (RTL) is conventional; respect safe areas on iOS/Android.
  • Motion: Honor prefers-reduced-motion with non‑essential animations disabled.
  • Z‑order: Appear above page content but below critical system UI (toasts, modals) unless intentionally elevated.

Core React FAB component (TypeScript)

The following component is composable, accessible, themeable, and can render via a portal to ensure it floats above layout content.

// Fab.tsx
import React, { forwardRef } from 'react';
import { createPortal } from 'react-dom';

type FabPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';

type FabProps = {
  icon: React.ReactNode;
  label?: string;               // optional visible label (extended FAB)
  ariaLabel?: string;           // required if no visible label
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
  position?: FabPosition;
  size?: 'regular' | 'large';
  variant?: 'brand' | 'surface';
  disabled?: boolean;
  portal?: boolean;             // render into document.body
  className?: string;
  id?: string;
};

function MaybePortal({ enabled, children }: { enabled?: boolean; children: React.ReactNode }) {
  if (!enabled || typeof window === 'undefined') return <>{children}</>;
  return createPortal(children, document.body);
}

export const Fab = forwardRef<HTMLButtonElement, FabProps>(function Fab(
  {
    icon,
    label,
    ariaLabel,
    onClick,
    position = 'bottom-right',
    size = 'regular',
    variant = 'brand',
    disabled,
    portal = true,
    className,
    id,
  },
  ref
) {
  const accName = label ?? ariaLabel;
  if (!accName) {
    // Guardrail for accessibility: ensure a name exists.
    console.warn('Fab requires either label or ariaLabel for accessibility.');
  }

  const classes = [
    'fab',
    `fab--${size}`,
    `fab--${variant}`,
    `fab--${position}`,
    className ?? ''
  ].join(' ');

  const content = (
    <button
      id={id}
      ref={ref}
      type="button"
      className={classes}
      aria-label={label ? undefined : ariaLabel}
      onClick={onClick}
      disabled={disabled}
    >
      <span className="fab__icon" aria-hidden>
        {icon}
      </span>
      {label && <span className="fab__label">{label}</span>}
    </button>
  );

  return <MaybePortal enabled={portal}>{content}</MaybePortal>;
});

Minimal CSS

Use CSS variables for theming and safe‑area insets to keep the FAB away from notches and system bars.

/* fab.css */
:root {
  --fab-size: 56px;
  --fab-size-lg: 64px;
  --fab-radius: 28px;
  --fab-bg: #5b6cff;         /* brand */
  --fab-fg: #ffffff;
  --fab-surface-bg: #ffffff; /* surface variant */
  --fab-surface-fg: #1f2937;
  --fab-shadow: 0 6px 16px rgba(0,0,0,.2), 0 2px 6px rgba(0,0,0,.18);
  --fab-z: 60;               /* below toasts/modals if they use 100+ */
  --fab-gap: 16px;
}

.fab {
  position: fixed;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: var(--fab-size);
  min-width: var(--fab-size);
  padding: 0 20px;            /* allows extended FAB with label */
  border-radius: 9999px;
  border: none;
  cursor: pointer;
  color: var(--fab-fg);
  background: var(--fab-bg);
  box-shadow: var(--fab-shadow);
  z-index: var(--fab-z);
  transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease;
  outline: none;
}
.fab--large { min-height: var(--fab-size-lg); min-width: var(--fab-size-lg); }
.fab__label { font-weight: 600; }
.fab__icon { display: grid; place-items: center; font-size: 20px; }

.fab:hover { transform: translateY(-1px); }
.fab:active { transform: translateY(0); box-shadow: 0 2px 8px rgba(0,0,0,.24); }
.fab:focus-visible { box-shadow: 0 0 0 4px rgba(59,130,246,.35), var(--fab-shadow); }

/* Surface variant (e.g., on dark backgrounds) */
.fab--surface { background: var(--fab-surface-bg); color: var(--fab-surface-fg); }

/* Corners + safe areas */
.fab--bottom-right { right: calc(var(--fab-gap) + env(safe-area-inset-right)); bottom: calc(var(--fab-gap) + env(safe-area-inset-bottom)); }
.fab--bottom-left  { left:  calc(var(--fab-gap) + env(safe-area-inset-left));  bottom: calc(var(--fab-gap) + env(safe-area-inset-bottom)); }
.fab--top-right    { right: calc(var(--fab-gap) + env(safe-area-inset-right)); top:    calc(var(--fab-gap) + env(safe-area-inset-top)); }
.fab--top-left     { left:  calc(var(--fab-gap) + env(safe-area-inset-left));  top:    calc(var(--fab-gap) + env(safe-area-inset-top)); }

/**** Reduced motion ****/
@media (prefers-reduced-motion: reduce) {
  .fab { transition: none; }
}

Usage

// App.tsx
import { Fab } from './Fab';
import './fab.css';
import { Plus } from './icons'; // any icon component or SVG

export default function App() {
  return (
    <>
      {/* page content */}
      <Fab
        icon={<Plus />}
        ariaLabel="Create new item"
        onClick={() => console.log('Create')}
        position="bottom-right"
        variant="brand"
        size="regular"
      />
    </>
  );
}

Rendering above everything with portals

Portaling to document.body avoids stacking‑context traps created by position and z-index in nested containers. The portal prop lets you turn this off if your app has a dedicated overlay root or you need the FAB inside a scrolling container.

Tip: If your app uses modals/toasts, reserve consistent z-index layers (e.g., FAB = 60, toasts = 90, modals = 110) to prevent overlap bugs.

Extended FABs and labels

When showing a text label, ensure the visual and accessible names match to reduce cognitive load. In the component above, specifying label renders readable text and uses it as the accessible name; otherwise provide ariaLabel.

Multi‑action “Speed Dial”

A speed dial reveals a short list of secondary actions when the FAB is activated. Keep it under ~5 items and ensure full keyboard and screen reader support.

// SpeedDial.tsx
import React, { useEffect, useId, useRef, useState } from 'react';
import { Fab } from './Fab';

type Action = { id?: string; icon: React.ReactNode; label: string; onClick: () => void; };

type SpeedDialProps = {
  actions: Action[];
  fabIcon: React.ReactNode;
  position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
};

export function SpeedDial({ actions, fabIcon, position = 'bottom-right' }: SpeedDialProps) {
  const [open, setOpen] = useState(false);
  const menuId = useId();
  const wrapperRef = useRef<HTMLDivElement>(null);
  const firstBtnRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    function onDocClick(e: MouseEvent) {
      if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
    }
    function onEsc(e: KeyboardEvent) {
      if (e.key === 'Escape') setOpen(false);
    }
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onEsc);
    return () => {
      document.removeEventListener('mousedown', onDocClick);
      document.removeEventListener('keydown', onEsc);
    };
  }, []);

  useEffect(() => { if (open) firstBtnRef.current?.focus(); }, [open]);

  return (
    <div ref={wrapperRef} className={`speeddial speeddial--${position}`}>
      {/* Action list */}
      <ul
        id={menuId}
        role="menu"
        aria-label="Available actions"
        className={`speeddial__list ${open ? 'is-open' : ''}`}
      >
        {actions.map((a, i) => (
          <li key={a.id ?? i} role="none">
            <button
              role="menuitem"
              ref={i === 0 ? firstBtnRef : undefined}
              className="speeddial__action"
              onClick={() => { a.onClick(); setOpen(false); }}
            >
              <span className="speeddial__icon" aria-hidden>{a.icon}</span>
              <span className="speeddial__label">{a.label}</span>
            </button>
          </li>
        ))}
      </ul>

      {/* Anchor FAB */}
      <Fab
        icon={fabIcon}
        ariaLabel={open ? 'Close actions' : 'Open actions'}
        position={position}
        onClick={() => setOpen(v => !v)}
        portal
      />
    </div>
  );
}

Add styles and a simple motion effect. Items should appear from the FAB, stacked away from it (upwards for a bottom FAB).

/* speeddial.css */
.speeddial { position: fixed; z-index: 61; }
.speeddial--bottom-right { right: calc(var(--fab-gap) + env(safe-area-inset-right)); bottom: calc(var(--fab-gap) + env(safe-area-inset-bottom)); }
.speeddial--bottom-left  { left:  calc(var(--fab-gap) + env(safe-area-inset-left));  bottom: calc(var(--fab-gap) + env(safe-area-inset-bottom)); }
.speeddial--top-right    { right: calc(var(--fab-gap) + env(safe-area-inset-right)); top:    calc(var(--fab-gap) + env(safe-area-inset-top)); }
.speeddial--top-left     { left:  calc(var(--fab-gap) + env(safe-area-inset-left));  top:    calc(var(--fab-gap) + env(safe-area-inset-top)); }

.speeddial__list {
  list-style: none; margin: 0; padding: 0; position: absolute; display: grid; gap: 10px;
  /* Start hidden, animate in */
  opacity: 0; pointer-events: none; transform: translateY(8px);
  transition: opacity .14s ease, transform .14s ease;
}

/* Default: stack upwards when at bottom; tweak per position */
.speeddial--bottom-right .speeddial__list,
.speeddial--bottom-left .speeddial__list { bottom: calc(var(--fab-size) + 12px); }
.speeddial--top-right .speeddial__list,
.speeddial--top-left .speeddial__list { top: calc(var(--fab-size) + 12px); }

.speeddial__list.is-open { opacity: 1; pointer-events: auto; transform: translateY(0); }

.speeddial__action {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 10px 14px; border-radius: 10px; border: 1px solid rgba(0,0,0,.08);
  background: #111827; color: #fff; /* dark chip */
  box-shadow: 0 4px 12px rgba(0,0,0,.2);
}
.speeddial__action:focus-visible { outline: 3px solid rgba(59,130,246,.5); outline-offset: 2px; }
.speeddial__label { font-size: 14px; font-weight: 600; }
.speeddial__icon { width: 20px; height: 20px; display: grid; place-items: center; }

@media (prefers-reduced-motion: reduce) {
  .speeddial__list { transition: none; }
}

Example

import { SpeedDial } from './SpeedDial';
import './speeddial.css';
import { Plus, Camera, Upload, Edit } from './icons';

export function ExampleDial() {
  return (
    <SpeedDial
      fabIcon={<Plus />}
      position="bottom-right"
      actions=[
        { icon: <Camera />, label: 'Capture', onClick: () => console.log('Capture') },
        { icon: <Upload />, label: 'Upload', onClick: () => console.log('Upload') },
        { icon: <Edit />,   label: 'Compose', onClick: () => console.log('Compose') },
      ]
    />
  );
}

Theming and dark mode

Because the FAB uses CSS variables, you can theme it globally or per‑container:

/* Brand theme */
:root {
  --fab-bg: #7c3aed;  /* violet-600 */
  --fab-fg: #ffffff;
}

/* Dark mode override */
@media (prefers-color-scheme: dark) {
  :root {
    --fab-bg: #8b5cf6;
    --fab-surface-bg: #1f2937;
    --fab-surface-fg: #f9fafb;
  }
}

If you use a design system (e.g., Tailwind, CSS‑in‑JS), map tokens to --fab-* variables to keep the API stable across styling solutions.

Keyboard support details

  • FAB: native button semantics handle Enter/Space. Keep tabindex default for proper tab order.
  • Speed dial:
    • The anchor FAB toggles aria-expanded or uses distinct aria-label states (as shown) to communicate open/closed.
    • The list uses role="menu" and each action uses role="menuitem" for screen reader context.
    • Close on Escape and outside click. Move focus to the first action on open to reduce keystrokes.

For complex menus, consider a roving tabindex pattern and arrow‑key navigation. Keep it simple if you have only a few actions.

Performance considerations

  • Keep the FAB outside heavily re‑rendering trees (use the portal option) to avoid unnecessary updates.
  • Prefer inline SVGs for icons to minimize layout shifts.
  • Memoize icon components if they are heavy.

Integration tips

  • Next.js/SSR: Guard access to document (the provided MaybePortal handles this). Import styles in client components or global CSS.
  • Mobile web: Respect env(safe-area-inset-*) insets; test on iOS Safari with notched devices.
  • Layout collisions: Ensure bottom sheets, cookie banners, or chat widgets reserve room so the FAB does not block critical controls.

Testing with React Testing Library

// Fab.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Fab } from './Fab';

test('announces accessible name and fires click', () => {
  const onClick = jest.fn();
  render(<Fab icon={<span>+</span>} ariaLabel="Create" onClick={onClick} portal={false} />);
  const btn = screen.getByRole('button', { name: /create/i });
  fireEvent.click(btn);
  expect(onClick).toHaveBeenCalled();
});

// SpeedDial focus behavior
import { SpeedDial } from './SpeedDial';

it('moves focus to first action when opened', () => {
  render(<SpeedDial fabIcon={<span>+</span>} actions={[{ icon: 'C', label: 'Capture', onClick: () => {} }]} />);
  const toggle = screen.getByRole('button');
  fireEvent.click(toggle);
  expect(screen.getByRole('menuitem', { name: /capture/i })).toHaveFocus();
});

Common pitfalls

  • Missing accessible name: a plain icon without aria-label is invisible to screen readers.
  • Overuse: if every screen has a FAB, none of them feel primary. Use sparingly.
  • Obscured content: test at 320 px wide and with browser zoom at 200%.
  • Stacking context surprises: ancestor transforms or position: sticky can create unexpected layers—use portals.
  • Motion sickness: unchecked animations can violate prefers-reduced-motion expectations.

Variations and enhancements

  • Extended FAB: show a label permanently (already supported with the label prop).
  • Contextual FAB: change icon/label based on route or selection; ensure it remains predictable.
  • Animated reveal: add a scale/opacity entrance on page load, disabled for reduced motion.
  • Haptics: on mobile apps (React Native/web AppShell), consider subtle haptic feedback for presses.

Conclusion

A React FAB is straightforward to implement yet easy to get wrong without careful attention to accessibility, motion, placement, and safe‑area handling. The components above provide a clean foundation you can drop into most apps. Keep the action singular and meaningful, respect device constraints, and validate with keyboard and screen reader testing—your users will thank you.

Related Posts