FAB Stack
Buttons

FAB Stack

A floating action button that expands a vertical stack of labeled mini-actions with a 40ms stagger, container scrim, arrow-key traversal, and Escape to close.

Install

npx shadcn@latest add @paragon/fab-stack

fab-stack.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";

export interface FabStackAction {
  /** Visible label pill next to the mini action. */
  label: string;
  icon: React.ReactNode;
  onSelect?: () => void;
}

export interface FabStackProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  actions: FabStackAction[];
  /** Icon in the main button. Rotates 45° while open. Defaults to a plus. */
  icon?: React.ReactNode;
  /** Accessible name for the main button. */
  label?: string;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Corner of the nearest `position: relative` ancestor. */
  position?: "bottom-right" | "bottom-left";
  /** Dim the surrounding container while open. */
  scrim?: boolean;
  /** Closes the stack after an action is selected. */
  closeOnSelect?: boolean;
  /** Disables the stagger/rise motion; items fade instantly. */
  static?: boolean;
}

/**
 * A floating action button that expands a vertical stack of labeled
 * mini-actions with a 40ms stagger (rise + blur enter, quick uniform
 * exit). A scrim dims the surrounding container, Escape or an outside
 * click closes, and focus returns to the main button. Position it inside
 * a `relative` container.
 */
export function FabStack({
  actions,
  icon,
  label = "Actions",
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  position = "bottom-right",
  scrim = true,
  closeOnSelect = true,
  static: isStatic = false,
  className,
  ...props
}: FabStackProps) {
  const reducedMotion = useReducedMotion();
  const instant = isStatic || !!reducedMotion;
  const stackId = React.useId();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const mainRef = React.useRef<HTMLButtonElement>(null);

  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : uncontrolledOpen;

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (!isControlled) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  // Escape closes and hands focus back to the main button.
  React.useEffect(() => {
    if (!open) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.stopPropagation();
        setOpen(false);
        mainRef.current?.focus();
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [open, setOpen]);

  // Arrow keys walk the stack: main button ↔ mini-actions.
  const handleRootKeyDown = (event: React.KeyboardEvent) => {
    if (!open || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
    const root = rootRef.current;
    if (!root) return;
    const items = Array.from(
      root.querySelectorAll<HTMLButtonElement>("[data-fab-stack-item]"),
    );
    const order = [mainRef.current, ...items].filter(
      (el): el is HTMLButtonElement => el !== null,
    );
    const index = order.indexOf(document.activeElement as HTMLButtonElement);
    if (index === -1) return;
    event.preventDefault();
    const next =
      event.key === "ArrowUp"
        ? Math.min(index + 1, order.length - 1)
        : Math.max(index - 1, 0);
    order[next]?.focus();
  };

  const right = position === "bottom-right";

  return (
    <>
      <AnimatePresence>
        {scrim && open && (
          <motion.div
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: instant ? 0 : 0.15, ease: "easeOut" }}
            onClick={() => setOpen(false)}
            className="absolute inset-0 z-20 rounded-[inherit] bg-background/60"
          />
        )}
      </AnimatePresence>

      <div
        ref={rootRef}
        data-slot="fab-stack"
        onKeyDown={handleRootKeyDown}
        className={cn(
          "absolute bottom-4 z-30 flex flex-col-reverse gap-2.5",
          right ? "right-4 items-end" : "left-4 items-start",
          className,
        )}
        {...props}
      >
        <button
          ref={mainRef}
          type="button"
          aria-label={label}
          aria-expanded={open}
          aria-controls={open ? stackId : undefined}
          onClick={() => setOpen(!open)}
          className={cn(
            "inline-flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-overlay outline-none",
            "transition-[scale,background-color,box-shadow] duration-150 ease-out hover:bg-primary/90",
            "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            "disabled:pointer-events-none disabled:opacity-50",
            !isStatic && "active:not-disabled:scale-[0.97]",
            "[&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
          )}
        >
          <span
            aria-hidden
            className={cn(
              "flex rotate-0 transition-[rotate] duration-200 ease-(--ease-out) motion-reduce:transition-none",
              open && "rotate-45",
            )}
          >
            {icon ?? <Plus />}
          </span>
        </button>

        <div
          id={stackId}
          role="group"
          aria-label={label}
          className={cn(
            "flex flex-col-reverse gap-2",
            right ? "items-end" : "items-start",
          )}
        >
          <AnimatePresence>
            {open &&
              actions.map((action, index) => (
                <motion.button
                  key={action.label}
                  type="button"
                  data-fab-stack-item=""
                  initial={
                    instant
                      ? { opacity: 0 }
                      : {
                          opacity: 0,
                          y: 8,
                          scale: 0.9,
                          filter: "blur(4px)",
                        }
                  }
                  animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
                  exit={
                    instant
                      ? { opacity: 0, transition: { duration: 0 } }
                      : {
                          opacity: 0,
                          y: 6,
                          filter: "blur(4px)",
                          transition: { duration: 0.12, ease: "easeIn" },
                        }
                  }
                  transition={{
                    type: "spring",
                    duration: 0.35,
                    bounce: 0,
                    delay: instant ? 0 : index * 0.04,
                  }}
                  onClick={() => {
                    action.onSelect?.();
                    if (closeOnSelect) {
                      setOpen(false);
                      mainRef.current?.focus();
                    }
                  }}
                  className={cn(
                    "group/fab-item flex items-center gap-2.5 rounded-full outline-none",
                    right ? "flex-row" : "flex-row-reverse",
                    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                  )}
                >
                  <span className="rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-border">
                    {action.label}
                  </span>
                  <span
                    className={cn(
                      "flex size-10 items-center justify-center rounded-full bg-card text-foreground shadow-border",
                      "transition-[scale,background-color,box-shadow] duration-150 ease-out",
                      "group-hover/fab-item:bg-accent group-hover/fab-item:shadow-border-hover",
                      !isStatic && "group-active/fab-item:scale-[0.97]",
                      "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
                    )}
                  >
                    {action.icon}
                  </span>
                </motion.button>
              ))}
          </AnimatePresence>
        </div>
      </div>
    </>
  );
}