EmpireUI
Get Pro
← Blog7 min read#floating-action-button#react#tailwind-css

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.

A mobile app interface showing a circular floating action button in the bottom-right corner with speed dial menu expanded

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

How do I prevent the FAB from overlapping content in a layout with a fixed bottom nav bar?

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.

Should speed dial actions open upward or sideways?

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.

How many actions should a speed dial FAB have?

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.

Why does my FAB disappear behind a modal or drawer?

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.

Can I animate the FAB in on page load without it feeling jarring?

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.

How do I handle the FAB in server-side rendered React apps like Next.js?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React UI Components Complete Reference: 60+ Patterns with CodeImage Gallery with Lightbox: Accessible Photo Viewer in ReactGlow Button in React: CSS Box Shadow Animation on HoverNeumorphism Icon Buttons: Soft UI Action Controls