Floating Action Button in React: FAB with Speed Dial
Build a floating action button with speed dial in React and Tailwind. Covers accessibility, animation, positioning, and Empire UI component patterns — no library bloat.
What Is a Floating Action Button (FAB)?
Honestly, the FAB is one of those UI patterns that developers either love or quietly resent. It floats. It's always visible. And when done right, it solves a real navigation problem — surfacing the one action users reach for most without burying it in a toolbar or nav bar.
The pattern comes from Material Design, where a FAB represents the primary action on a screen. In web apps and SPAs built with React, it's become a staple for things like 'New Post', 'Add to Cart', or 'Start Chat'. The fixed positioning keeps it anchored at the viewport edge regardless of scroll depth.
Speed dial extends the concept. One button expands into multiple sub-actions arranged in an arc or column. It's compact when idle, expressive when open. You get all the real estate back while still giving users three or four actions within a single thumb reach.
FAB Positioning with Tailwind CSS
Getting the position right is the first thing you'll need to nail. With Tailwind v4.0.2, fixed positioning is straightforward — fixed bottom-6 right-6 gives you the standard 24px offset from the bottom-right corner. That's the sweet spot for mobile-first layouts.
You might wonder: what about layouts where a bottom navigation bar is present? Good question. You'll need to push the FAB up by the height of that nav — typically bottom-20 (80px) or even bottom-24 (96px) depending on your nav's height. On desktop, keeping it at bottom-6 with a z-50 is usually fine since most desktop layouts don't have fixed bottom bars.
One thing that bites people: forgetting to set a z-index. Without it, your FAB ends up underneath modals, drawers, or sticky headers. Stick a z-50 on the container. If you're running multiple stacked fixed elements, bump it to z-[60] for the FAB specifically and be deliberate about the stacking order across your app.
Building the Core FAB Component in React
Here's the base FAB component. It's a single button, fixed to the viewport, with a simple Tailwind ring focus style and an aria-label so screen readers know what's happening.
import { useState } from 'react';
interface FABProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
color?: string;
}
export function FloatingActionButton({
icon,
label,
onClick,
color = 'bg-violet-600 hover:bg-violet-700',
}: FABProps) {
return (
<button
onClick={onClick}
aria-label={label}
className={`
fixed bottom-6 right-6 z-50
w-14 h-14 rounded-full shadow-lg
flex items-center justify-center
text-white transition-all duration-200
focus:outline-none focus:ring-4 focus:ring-violet-400/60
active:scale-95
${color}
`}
>
{icon}
</button>
);
}Notice the active:scale-95 class. That's a subtle press feedback that costs you zero JavaScript. It feels satisfying on click without needing framer-motion for a simple FAB. Pair this with animated button patterns from Empire UI if you want to push the effect further.
Speed Dial: Expanding into Sub-Actions
The speed dial is where things get interesting. The idea is: one main FAB, and when you press it a group of smaller action buttons fans out above it. Each child button needs its own label (tooltip or visible text), its own icon, and an onClick handler.
import { useState } from 'react';
interface SpeedDialAction {
icon: React.ReactNode;
label: string;
onClick: () => void;
}
interface SpeedDialProps {
actions: SpeedDialAction[];
mainIcon: React.ReactNode;
mainLabel: string;
}
export function SpeedDial({ actions, mainIcon, mainLabel }: SpeedDialProps) {
const [open, setOpen] = useState(false);
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col-reverse items-end gap-3">
{/* Sub-actions */}
<div
className={`
flex flex-col-reverse items-end gap-3
transition-all duration-200 origin-bottom
${open ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-75 pointer-events-none'}
`}
aria-hidden={!open}
>
{actions.map((action, i) => (
<div key={i} className="flex items-center gap-3">
<span className="bg-gray-900 text-white text-sm px-2 py-1 rounded shadow">
{action.label}
</span>
<button
onClick={() => {
action.onClick();
setOpen(false);
}}
aria-label={action.label}
className="
w-10 h-10 rounded-full shadow-md
bg-white text-gray-800
flex items-center justify-center
hover:bg-gray-50 active:scale-95
transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-violet-400
"
>
{action.icon}
</button>
</div>
))}
</div>
{/* Main FAB */}
<button
onClick={() => setOpen((prev) => !prev)}
aria-label={mainLabel}
aria-expanded={open}
className="
w-14 h-14 rounded-full shadow-lg
bg-violet-600 hover:bg-violet-700 text-white
flex items-center justify-center
transition-all duration-200
focus:outline-none focus:ring-4 focus:ring-violet-400/60
active:scale-95
"
>
<span
className={`transition-transform duration-200 ${open ? 'rotate-45' : 'rotate-0'}`}
>
{mainIcon}
</span>
</button>
</div>
);
}The rotate-45 on the main icon when open is true is the classic plus-to-X transform. It's a visual cue that's immediately understood by users. No icons package needed — a simple SVG + rotated 45 degrees reads as a close affordance. The scale-75 on the collapsed sub-actions means they shrink toward their origin rather than just blinking in and out.
Backdrop Overlay and Click-Outside to Close
Speed dial menus that stay open after you've clicked an action or tapped elsewhere are annoying. There are two solid approaches: a backdrop overlay, or a click-outside listener. The backdrop is simpler to implement and adds a nice dimming effect for mobile-focused apps.
Drop a div behind the speed dial that covers the whole viewport with fixed inset-0 bg-black/30 z-40 — only rendered when open is true. Clicking it closes the menu. The FAB and its children sit at z-50, so they stay on top. This approach also naturally blocks interactions with the page below, which is usually what you want when the dial is open.
If you'd rather avoid the overlay, a useEffect with a document-level mousedown listener and a ref pointing at the FAB container works fine. It's about 10 more lines but gives you the dismiss-on-click-outside behavior without any visual change to the page. For apps where the FAB co-exists with other interactive content, the ref approach is cleaner. You'll find a similar pattern used in animated tabs components where focus management matters a lot.
Accessibility: ARIA, Focus Trapping, and Keyboard Navigation
A FAB that only works with a mouse is a half-finished component. At minimum you need aria-label on the main button and aria-expanded to signal open/closed state. The sub-action buttons each need their own aria-label too — don't rely on the tooltip text being announced correctly, because it won't be on all screen readers.
Keyboard users should be able to open the dial with Enter or Space (default button behavior handles this), then Tab through the sub-actions in order, and close with Escape. That Escape handler is a one-liner inside a useEffect: if (e.key === 'Escape') setOpen(false). Don't skip it. It's the expected behavior and users relying on keyboard navigation will thank you.
Focus management is the trickier part. When the dial opens, focus should move to the first sub-action. When it closes — whether via Escape or a sub-action click — focus should return to the main FAB. You can handle this with a ref on the first sub-action and calling .focus() inside a useEffect that watches the open state.
Styling Variants: Dark Mode, Glass, and Empire UI Themes
The base violet FAB is fine, but you'll want variants. Dark mode is the obvious one. With Tailwind's dark: prefix, adding dark:bg-violet-500 dark:hover:bg-violet-400 and a dark:ring-violet-300/50 for focus does it. If your app uses Empire UI's theme toggle, the dark mode class gets applied to the root automatically — no extra work on the FAB side.
Glass morphism FABs are a popular choice right now. The sub-action buttons work especially well in glass style: bg-white/10 backdrop-blur-md border border-white/20 text-white over a blurred background. Set the main FAB's background to something like rgba(139, 92, 246, 0.85) with backdrop-blur-sm for a frosted glass feel. If you want to understand the theory behind that effect, this explainer on glassmorphism covers it well.
Empire UI ships 40 visual styles you can apply to components. The FAB and speed dial translate cleanly into most of them — neon glow, brutalist borders, soft neumorphism. The key is keeping the fixed positioning and z-50 outside the style token so swapping themes doesn't accidentally reset your stacking context. Keep structural classes separate from decorative ones.
Performance: Avoiding Rerender Traps in the Speed Dial
Speed dials can be subtle performance traps if you're not careful. The actions array prop is the main offender. If you define it inline in JSX — actions={[{ label: 'Share', ... }]} — you're creating a new array reference on every parent render. That's not the end of the world for a small component, but it'll cause unnecessary re-renders if you've memoized children.
Define the actions array outside the component or wrap it in useMemo if it depends on state. Same goes for the icon elements — if you're passing JSX nodes as icon, keep them stable with useMemo or move them to constants. For most apps this is a micro-optimization, but it matters in dashboards or feeds where the parent renders frequently, like the card-heavy layouts you'd see in a card stack component.
Animation performance is worth checking too. The transition-all class on the sub-actions container is convenient but it animates every CSS property, including expensive ones like height. Replace it with explicit transition-[opacity,transform] once you're happy with the design. That limits the work to GPU-composited properties — opacity and transform — which means smooth 60fps animation even on lower-end devices.
FAQ
Increase the bottom offset. If your bottom nav is 64px tall (Tailwind's h-16), use bottom-20 (80px) on the FAB so there's a 16px gap above the nav. For a 56px nav, bottom-[72px] with an arbitrary value gets you the same result. You can also expose a bottomOffset prop on your FAB component and pass it as an inline style: style={{ bottom: bottomOffset }} — useful when the offset comes from a layout context.
Upward is the convention and it's what users expect from Material Design and iOS patterns. Sideways works if the FAB is in a corner and you have horizontal real estate, but it feels unfamiliar to most users. If the FAB is in the bottom-right corner, actions expanding upward and slightly left (a diagonal arc) can look polished. The Tailwind implementation above uses a vertical column, which is the simplest to build and most accessible for keyboard navigation.
Three to five is the sweet spot. More than five and the dial starts to feel like a menu — at that point you probably want a proper drawer or modal instead. Fewer than two means you don't need speed dial at all; a single FAB with a direct action is cleaner. Each action should be a distinct, primary-level task, not a sub-item. If you're grouping related options, consider a contextual menu instead.
Stacking context conflict. The FAB has z-50, but if your modal is inside a parent with a transform, opacity, or filter CSS property applied, it creates a new stacking context and the FAB's z-index competes within that context rather than globally. Check if any ancestor of the modal has one of those properties. The fix is usually to portal the FAB (or the modal) to the document body using React's createPortal, so both elements share the same top-level stacking context.
Yes — a gentle scale-in with a short delay works well. Add animate-in zoom-in-75 duration-300 delay-500 if you're using Tailwind's animation utilities, or handle it with a CSS @keyframes that scales from 0.75 to 1 with an ease-out curve over 250ms. The 500ms delay lets the main page content appear first so the FAB doesn't distract from the initial load. Avoid entrance animations longer than 300ms for a fixed UI element — it feels sluggish.
The FAB uses useState for the open/closed toggle, which is client-only. Mark the component with 'use client' at the top. The fixed positioning means the FAB isn't really part of the SSR output that matters for SEO — it's pure interaction UI. You can lazy-load it with next/dynamic and { ssr: false } if you want to keep it out of the initial bundle entirely, which is a valid optimization if the FAB is only shown to authenticated users.