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.
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-labelif 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-motionwith 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
tabindexdefault for proper tab order. - Speed dial:
- The anchor FAB toggles
aria-expandedor uses distinctaria-labelstates (as shown) to communicate open/closed. - The list uses
role="menu"and each action usesrole="menuitem"for screen reader context. - Close on Escape and outside click. Move focus to the first action on open to reduce keystrokes.
- The anchor FAB toggles
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
portaloption) 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 providedMaybePortalhandles 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-labelis 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: stickycan create unexpected layers—use portals. - Motion sickness: unchecked animations can violate
prefers-reduced-motionexpectations.
Variations and enhancements
- Extended FAB: show a label permanently (already supported with the
labelprop). - 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
Building an Accessible, Responsive React Collapsible Sidebar Navigation
Build a fast, accessible, responsive React collapsible sidebar with TypeScript, ARIA, keyboard support, and persisted state—no UI library required.
React Skeleton Screen Shimmer Effect: Accessible and Fast
Build an accessible, high-performance React skeleton screen with a polished shimmer effect, plus Suspense integration, theming, and pro tips.
Build an Accessible React Badge Notification Counter (With Real‑Time Updates and Animations)
Build an accessible, themeable React badge notification counter with real-time updates, tasteful animations, and robust a11y—plus tests and tips.