EmpireUI
Get Pro
← Blog8 min read#fab#floating action button#react

Floating Action Button (FAB) in React: Mobile-First Speed Dial

Build a production-ready Floating Action Button in React with Tailwind — including speed dial, animations, and mobile-first accessibility patterns that actually work.

code editor screen showing React component UI on dark background

What Is a Floating Action Button and When Should You Use One?

A Floating Action Button — FAB in shorthand — is that circular button pinned to the bottom-right corner of the screen that follows you as you scroll. Material Design 2 (2018) popularized it. It's designed to surface a single primary action: compose, create, add. One button. One job. That's the whole contract.

Where people go wrong is treating the FAB like a Swiss Army knife. You'll see codebases where someone has a FAB that opens a menu with seven options, half of which could've lived in a nav bar. If you've got more than three actions to expose, a speed dial pattern (a FAB that expands into a stack of smaller action buttons) is the right call — but even then, ask yourself honestly whether your mobile users actually need all seven of those actions reachable from every single screen.

In practice, FABs shine in content-heavy apps: note-taking, social feeds, e-commerce product lists, dashboards. Anywhere the user's primary intent is to create something new. They're less appropriate on landing pages or settings screens where you'd just... use a regular button. That said, with the right animation, they can add a premium feel to almost any mobile-first interface — which is where the fun begins.

Worth noting: the FAB pattern works cross-style-system. Whether you're building something with glassmorphism components, going raw with neobrutalism, or keeping it clean and flat, the component mechanics stay identical. Only the visual treatment changes.

Building the Base FAB Component in React + Tailwind

Let's start with the simplest possible version — a fixed-position button that renders in the bottom-right corner. Tailwind makes this almost embarrassingly easy, but there are a handful of gotchas you'll hit if you've never built one before.

// FAB.tsx
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';

interface FABProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  icon: React.ReactNode;
  label: string; // for aria-label — never skip this
  position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
  size?: 'sm' | 'md' | 'lg';
}

const positionMap = {
  'bottom-right': 'bottom-6 right-6',
  'bottom-left': 'bottom-6 left-6',
  'bottom-center': 'bottom-6 left-1/2 -translate-x-1/2',
};

const sizeMap = {
  sm: 'h-12 w-12',
  md: 'h-14 w-14',
  lg: 'h-16 w-16',
};

export const FAB = forwardRef<HTMLButtonElement, FABProps>(
  ({ icon, label, position = 'bottom-right', size = 'md', className, ...props }, ref) => {
    return (
      <button
        ref={ref}
        aria-label={label}
        className={cn(
          'fixed z-50 flex items-center justify-center rounded-full',
          'bg-violet-600 text-white shadow-lg shadow-violet-500/30',
          'transition-transform duration-200 ease-out',
          'hover:scale-110 active:scale-95',
          'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-violet-400',
          positionMap[position],
          sizeMap[size],
          className
        )}
        {...props}
      >
        {icon}
      </button>
    );
  }
);

FAB.displayName = 'FAB';

A few decisions worth explaining here. The forwardRef wrapper isn't optional — if you want to attach a tooltip library, a Radix UI popover, or a ref-driven animation later, you'll need it. The shadow-violet-500/30 is Tailwind's colored shadow syntax, and it makes a huge difference: plain shadow-lg on a colored button looks flat and 2014. The 30% opacity tinted shadow looks intentional.

The active:scale-95 gives you that satisfying press-down feedback on mobile without any JS. Combined with hover:scale-110, you get a full interaction arc in 48 characters of class names. That's the power of Tailwind's built-in transition utilities — no keyframe files, no external packages.

One more thing — the z-50 is deliberate. In most projects z-50 clears modals, drawers, and sticky headers. If you're running a heavily layered UI (think three-panel dashboards), bump it to z-[9999] and document why. Future-you will thank you.

Speed Dial: Expanding the FAB into Multiple Actions

Speed dial is where FABs get genuinely interesting — and where most tutorial code falls apart. The challenge is animating child buttons in and out cleanly, managing focus correctly, and not creating an accessibility nightmare in the process. Let's do all three.

// SpeedDial.tsx
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';

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);
  const containerRef = useRef<HTMLDivElement>(null);

  // Close on outside click
  useEffect(() => {
    if (!open) return;
    function handleClick(e: MouseEvent) {
      if (!containerRef.current?.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', handleClick);
    return () => document.removeEventListener('mousedown', handleClick);
  }, [open]);

  return (
    <div ref={containerRef} className="fixed bottom-6 right-6 z-50 flex flex-col-reverse items-center gap-3">
      {/* Child action buttons */}
      {actions.map((action, i) => (
        <button
          key={action.label}
          aria-label={action.label}
          onClick={() => { action.onClick(); setOpen(false); }}
          className={cn(
            'flex h-12 w-12 items-center justify-center rounded-full',
            'bg-white text-gray-800 shadow-md',
            'transition-all duration-200 ease-out',
            'hover:bg-gray-50 active:scale-95',
            open
              ? 'translate-y-0 opacity-100'
              : 'translate-y-4 opacity-0 pointer-events-none'
          )}
          style={{
            transitionDelay: open ? `${i * 40}ms` : '0ms',
          }}
          tabIndex={open ? 0 : -1}
        >
          {action.icon}
        </button>
      ))}

      {/* Main FAB */}
      <button
        aria-expanded={open}
        aria-label={mainLabel}
        onClick={() => setOpen(prev => !prev)}
        className={cn(
          'flex h-14 w-14 items-center justify-center rounded-full',
          'bg-violet-600 text-white shadow-lg shadow-violet-500/30',
          'transition-transform duration-300 ease-in-out',
          'hover:scale-110 active:scale-95',
          'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-violet-400',
          open && 'rotate-45'
        )}
      >
        {mainIcon}
      </button>
    </div>
  );
}

The staggered transitionDelay is the detail that separates this from the average copy-paste implementation. Each child button delays by 40ms × its index, so they fan out sequentially rather than all popping in at once. You're spending roughly 160ms total on a four-button spread — long enough to feel intentional, short enough not to be annoying.

The tabIndex={open ? 0 : -1} toggle is non-negotiable for keyboard accessibility. When the dial is closed, those child buttons don't exist from a keyboard perspective. When it opens, they're tabbable. aria-expanded on the main button tells screen readers what state the control is in. Honestly, most FAB tutorials on the internet skip all of this — and then developers wonder why their accessibility audits come back red.

Quick aside: if you want the child action buttons to show text labels (the tooltip style you see in Material Design 3), add a <span> to the left of each icon button with absolute right-full mr-3 whitespace-nowrap text-sm font-medium. They appear inline with the button, scale with the same animation, and look clean on both light and dark backgrounds.

Tailwind Animations and the Polish Layer

The base transitions above work, but if you want that elastic, app-like feel, you'll want custom easing curves. Tailwind's default ease-out is linear at the end — fine for most things, but a spring-like bounce makes FABs feel physical. Here's a minimal addition to your tailwind.config.ts:

// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  theme: {
    extend: {
      transitionTimingFunction: {
        spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
        'in-back': 'cubic-bezier(0.36, 0, 0.66, -0.56)',
      },
      keyframes: {
        'fab-in': {
          '0%': { transform: 'scale(0) rotate(-45deg)', opacity: '0' },
          '100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' },
        },
        'fab-out': {
          '0%': { transform: 'scale(1)', opacity: '1' },
          '100%': { transform: 'scale(0)', opacity: '0' },
        },
      },
      animation: {
        'fab-in': 'fab-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards',
        'fab-out': 'fab-out 0.15s ease-in forwards',
      },
    },
  },
} satisfies Config;

The cubic-bezier(0.34, 1.56, 0.64, 1) is the magic number. That mid-curve overshoot past 1.0 is what creates the bouncy entry. The exit uses a plain ease-in — exits should be fast and clean, never bouncey. That asymmetry is what makes interactions feel native.

Look, you can also reach for Framer Motion here and get even finer control with AnimatePresence and spring physics. For most projects, the CSS approach above is lighter and just as convincing. But if you're already using Framer Motion across your project (check your bundle — it's 40–80KB depending on what you tree-shake), you might as well use it for the FAB too and keep things consistent. Either way, browse the Empire UI component library to see how these animation patterns are applied across all the pre-built components.

Worth noting: on iOS Safari as of 2025, position: fixed elements inside a scroll container can drift during momentum scrolling. The fix is adding transform: translateZ(0) (Tailwind's translate-z-0 or just a raw style) to force GPU compositing. It's a one-liner but it'll save you a bug report from someone on an iPhone 13.

Mobile-First Patterns: Safe Areas, Scroll Behavior, and Touch Targets

The FAB lives at the bottom of the screen on mobile — which means it sits right on top of iOS's home indicator and Android's gesture navigation bar. Without safe area insets, your button will be partially obscured on most phones made after 2018. The fix is a single CSS variable.

// Use env() safe area insets for bottom positioning
<button
  className="fixed right-4 z-50 h-14 w-14 ..."
  style={{ bottom: 'calc(1.5rem + env(safe-area-inset-bottom, 0px))' }}
>
  {icon}
</button>

The env(safe-area-inset-bottom, 0px) fallback means desktop and older browsers get the normal 1.5rem bottom margin and nothing breaks. On an iPhone 15 Pro with the home swipe bar, you get the correct 34px extra clearance. This is one of those tiny details that separates something that looks like a tutorial from something that ships.

Touch target size is the other one. Apple's HIG and Google's Material both specify 48×48px minimums. A 40×40px FAB looks perfectly fine on desktop but is genuinely hard to tap accurately on a phone one-handed in the rain. Stick to h-14 w-14 (56px) for your main FAB at minimum. If you're using the sm size variant, it's fine for secondary action buttons in the speed dial where the primary FAB is already giving the user a large tap zone.

One more thing — if your page has a sticky bottom navigation bar (common in mobile-first React apps and Next.js projects), your FAB needs to account for that bar's height too. Don't hard-code bottom-20 and call it a day. Pass the offset as a prop or a CSS custom property so the component adapts. Something like --bottom-nav-height: 64px set at the layout level keeps things flexible when design inevitably changes that bar from 64px to 72px in Q3. Check out the templates section to see how Empire UI handles this at the layout level.

Integrating a FAB into a Next.js App Router Layout

In Next.js 13+ with the App Router, global UI elements like FABs belong in your root layout.tsx. You don't want to re-mount the FAB on every route change, and you don't want it on every page (admin panels, auth flows, settings — none of these need a compose button floating around). Here's a clean way to handle that with a client component boundary.

// app/layout.tsx
import { FABProvider } from '@/components/fab/FABProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <FABProvider />
      </body>
    </html>
  );
}

// components/fab/FABProvider.tsx
'use client';
import { usePathname } from 'next/navigation';
import { SpeedDial } from './SpeedDial';
import { PlusIcon } from 'lucide-react';

const FAB_ROUTES = ['/', '/feed', '/explore'];

export function FABProvider() {
  const pathname = usePathname();
  
  if (!FAB_ROUTES.some(r => pathname === r || pathname.startsWith(r + '/'))) {
    return null;
  }

  return (
    <SpeedDial
      mainIcon={<PlusIcon size={24} />}
      mainLabel="Create new content"
      actions={[
        { icon: <span>📝</span>, label: 'New note', onClick: () => {} },
        { icon: <span>📷</span>, label: 'Upload photo', onClick: () => {} },
        { icon: <span>🔗</span>, label: 'Share link', onClick: () => {} },
      ]}
    />
  );
}

The allowlist approach (FAB_ROUTES) is simpler than a denylist in most apps because you'll have more pages where the FAB *shouldn't* appear than ones where it should. Swap the logic if your app is the opposite. The usePathname hook from next/navigation is reactive, so the FAB mounts and unmounts correctly as you navigate between routes.

For state-driven FABs — where clicking the FAB should trigger something in a deeply nested component — reach for Zustand or React Context rather than prop-drilling. A useFABStore with an onPress callback is 20 lines and avoids the prop tunneling nightmare.

In practice, the FAB is a small component but it touches your routing, your layout architecture, your animation system, and your accessibility story all at once. Getting it right pays dividends across the whole app. And if you want a visual reference for how premium FAB animations look in context, browse the Empire UI library — the interaction patterns there are what you're aiming for.

FAQ

Should I use a FAB or a sticky bottom bar for mobile navigation?

Different jobs. A bottom bar handles navigation between sections; a FAB triggers a primary action like creating something. Use both together when needed — just make sure the FAB clears the bar's height.

Can I animate the FAB with CSS only, or do I need Framer Motion?

CSS transitions handle 90% of FAB animations cleanly. Reach for Framer Motion only if you're already using it and need spring physics or exit animations that can't be done with CSS alone.

What's the right z-index for a FAB in a Next.js project?

z-50 (Tailwind) clears most modals and sticky headers. If you have a custom modal system with very high z-index values, use z-[9999] and document it — mystery z-indexes cause more bugs than they solve.

How do I make the FAB accessible for screen readers and keyboard users?

Add aria-label to the main button, use aria-expanded for speed dials, and toggle tabIndex={-1} on hidden child buttons. These three attributes cover the major screen reader and keyboard navigation cases.

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

Read next

Color Picker in React: HSL Slider, Hex Input and Copy ButtonFree Stacked Cards Component for React — Cards Stack AnimationGradient Button in React + Tailwind: Hover, Focus and Active StatesButton Component Variants in Tailwind: Primary, Ghost, Icon, Loading