# Dock Expandable FAB — React Menu Component

> A floating action button that turns its plus into a cross and fires a speed-dial of labelled actions upward with staggered springs.

- Category: [Docks & Menus](https://pixelpapa.com/components/docks.md)
- Tags: fab, speed-dial, floating, actions, stagger
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/docks/dock-expandable-fab

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/dock-expandable-fab.json"
```

The file is written to `components/pixelpapa/dock-expandable-fab.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Camera, FileText, ListChecks, Mic, Plus, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type SpeedDialAction = { label: string; icon: LucideIcon; onSelect?: () => void };

type SpeedDialProps = {
  actions: SpeedDialAction[];
  /** Accessible name of the main button. */
  label?: string;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
};

export function SpeedDial({ actions, label = "Create", defaultOpen = false, onOpenChange, className }: SpeedDialProps) {
  const root = useRef<HTMLDivElement>(null);
  const fab = useRef<HTMLButtonElement>(null);
  const [open, setOpenState] = useState(defaultOpen);

  function setOpen(next: boolean) {
    setOpenState(next);
    onOpenChange?.(next);
  }

  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const items = [...(root.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
    const at = items.indexOf(document.activeElement as HTMLElement);
    if (e.key === "Escape" && open) {
      setOpen(false);
      fab.current?.focus();
    } else if (e.key === "ArrowUp" && open) (items[at + 1] ?? items[0])?.focus();
    else if (e.key === "ArrowDown" && open) (at <= 0 ? fab.current : items[at - 1])?.focus();
    else return;
    e.preventDefault();
  }

  return (
    <div ref={root} onKeyDown={onKeyDown} className={cn("relative flex flex-col items-end", className)}>
      <AnimatePresence>
        {open && (
          <div role="menu" aria-label={label} className="absolute right-1.5 bottom-full mb-3 flex flex-col-reverse items-end gap-2.5">
            {actions.map((a, i) => (
              <motion.button
                key={a.label}
                role="menuitem"
                onClick={() => {
                  a.onSelect?.();
                  setOpen(false);
                }}
                initial={{ opacity: 0, y: 28 + i * 14, scale: 0.4 }}
                animate={{ opacity: 1, y: 0, scale: 1, transition: { type: "spring", stiffness: 460, damping: 24, delay: i * 0.045 } }}
                exit={{ opacity: 0, y: 16, scale: 0.5, transition: { duration: 0.14, delay: (actions.length - 1 - i) * 0.025 } }}
                style={{ transformOrigin: "100% 100%" }}
                className="group flex items-center gap-3 rounded-full outline-offset-4"
              >
                <span className="rounded-lg bg-foreground px-2.5 py-1 text-xs font-medium whitespace-nowrap text-background shadow-md transition-transform group-hover:-translate-x-0.5">
                  {a.label}
                </span>
                <span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-card-foreground shadow-lg shadow-black/10 transition-colors group-hover:bg-primary group-hover:text-primary-foreground group-focus-visible:bg-primary group-focus-visible:text-primary-foreground">
                  <a.icon className="size-[18px]" />
                </span>
              </motion.button>
            ))}
          </div>
        )}
      </AnimatePresence>

      <motion.button
        ref={fab}
        aria-label={label}
        aria-haspopup="menu"
        aria-expanded={open}
        onClick={() => setOpen(!open)}
        whileTap={{ scale: 0.92 }}
        animate={{ rotate: open ? 135 : 0 }}
        transition={{ type: "spring", stiffness: 380, damping: 20 }}
        className="flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-xl shadow-primary/35 outline-offset-4"
      >
        <Plus className="size-6" strokeWidth={2.4} />
      </motion.button>
    </div>
  );
}

const notes = [
  ["Launch checklist", "7 of 12 done · edited today"],
  ["Interview: Harbour Coffee", "Voice memo · 18 min"],
  ["Pricing page copy", "Draft · 420 words"],
  ["Whiteboard, 4 Nov", "Photo · 2 pages"],
  ["Ideas for onboarding", "Note · edited last week"],
];

export default function DockExpandableFabDemo() {
  const [open, setOpen] = useState(true);
  const [last, setLast] = useState<string | null>(null);
  const pick = (what: string) => () => setLast(what);

  const actions: SpeedDialAction[] = [
    { label: "Text note", icon: FileText, onSelect: pick("Text note") },
    { label: "Checklist", icon: ListChecks, onSelect: pick("Checklist") },
    { label: "Voice memo", icon: Mic, onSelect: pick("Voice memo") },
    { label: "Scan a page", icon: Camera, onSelect: pick("Scan a page") },
  ];

  return (
    <div className="relative h-[420px] w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
      <motion.div animate={{ opacity: open ? 0.35 : 1, filter: open ? "blur(2px)" : "blur(0px)" }} transition={{ duration: 0.25 }} className="p-5">
        <p className="text-lg font-semibold tracking-tight text-card-foreground">Notes</p>
        <p className="text-xs text-muted-foreground">{last ? `Started a new “${last}”` : "5 notes · synced a minute ago"}</p>
        <ul className="mt-4 divide-y divide-border">
          {notes.map(([title, meta]) => (
            <li key={title} className="py-3">
              <p className="text-sm font-medium text-card-foreground">{title}</p>
              <p className="text-xs text-muted-foreground">{meta}</p>
            </li>
          ))}
        </ul>
      </motion.div>
      <SpeedDial actions={actions} label="New note" defaultOpen onOpenChange={setOpen} className="absolute right-5 bottom-5" />
    </div>
  );
}
```

## AI prompt

```text
Add the "Dock Expandable FAB" component from PixelPapa UI to this project.

What it is: A floating action button that turns its plus into a cross and fires a speed-dial of labelled actions upward with staggered springs.
Category: Docks & Menus. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/dock-expandable-fab.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/dock-expandable-fab.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/dock-expandable-fab" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More docks & menus

- [Dock Bottom Nav Mobile](https://pixelpapa.com/components/docks/dock-bottom-nav-mobile.md): A mobile tab bar where a blob slides under the active tab, squashing as it travels, while the icon hops up into it and its label fades in below.
- [Dock Floating Toolbar](https://pixelpapa.com/components/docks/dock-floating-toolbar.md) (Pro): A text-format toolbar that pops over a selection: toggles spring into their active state and the whole bar morphs into a link field and back.
- [Dock Magnify](https://pixelpapa.com/components/docks/dock-magnify.md): A dock whose icons swell by their distance to the cursor, lift their labels, bounce on launch and respond to keyboard focus the same way.
- [Menu Bar App](https://pixelpapa.com/components/docks/menu-bar-app.md) (Pro): A desktop-style File / Edit / View menubar: click once, then glide between menus as the highlight follows, with check items, shortcuts and full arrow-key navigation.
- [Menu Context Right Click](https://pixelpapa.com/components/docks/menu-context-right-click.md) (Pro): A custom context menu that opens at the pointer, flips to stay inside its area, scales from the corner nearest the click and is fully keyboard driven.
- [Menu Dropdown Nested](https://pixelpapa.com/components/docks/menu-dropdown-nested.md) (Pro): A dropdown with submenus to any depth: they open on hover intent or ArrowRight, scale out from their parent row and close level by level with ArrowLeft and Escape.
