Buttons

FAB Menu

A floating action button that fans a labelled column of secondary actions upward, with staggered entry and focus handling.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** per-item entrance offset; reversed on close so the column zips shut */
const STAGGER_MS = 45

export interface FabMenuAction {
  key: string
  label: string
  icon: React.ReactNode
  onSelect: () => void

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/fab-menu.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "FabMenu" component using lucide-react.
No animation library and no popover primitive — CSS transitions plus per-item
transition-delay do the whole reveal.

Contract
- forwardRef<HTMLDivElement, FabMenuProps> — the root is a relative inline-flex
  div; the forwarded ref and every extra native div prop land on it. The root ref
  is also used internally (outside-click test), so the callback ref writes both.
- actions: { key, label, icon, onSelect }[] — rendered bottom-up, index 0 sits
  closest to the trigger. onSelect is the consumer's handler.
- open?: boolean + onOpenChange?: (open: boolean) => void — controlled when
  `open` is passed, self-managed otherwise (the classic
  `const open = openProp ?? uncontrolled` pattern; onOpenChange fires in both).
- placement: "bottom-right" | "bottom-left" (default "bottom-right") — which
  edge the column and its labels hang from. Page position is NOT the component's
  job: the consumer passes className="fixed bottom-6 right-6" (or absolute,
  inside a positioned stage).
- icon / closeIcon: trigger glyphs (default Plus / X), showLabels: boolean
  (default true), overlay: boolean (default false), label: string — the
  accessible name for the trigger and the action group.

Behavior
- Clicking the trigger toggles the column; the glyph swaps and the wrapper spins
  90deg so Plus→X reads as one motion instead of a cut.
- Opening moves focus to the action nearest the trigger. A ref remembers the
  previous open value so a component that mounts already-open never steals focus
  on page load.
- Escape closes and returns focus to the trigger. Tab is NOT trapped: this is a
  non-modal overlay (the page behind it is never inert), so trapping Tab inside
  the column would violate WCAG 2.1.2. Focus simply walks the actions and the
  trigger in DOM order, and a focusout whose relatedTarget lands outside the root
  closes the menu without moving focus. Do the close on focusout, not on the Tab
  keydown: unmounting the focused row before the browser resolves the next tab
  stop is exactly what drops focus on document.body. Ignore a null relatedTarget
  (window blur, or Safari not focusing a button on mousedown) — closing there
  would swallow the pending click.
- Selecting an action calls onSelect(), closes, and restores focus to the trigger.
- Dismissal has two layers: a document "pointerdown" listener registered only
  while open (removed on close and unmount) for genuine outside clicks, and the
  optional scrim's own onClick — the scrim is a child of the root, so it would
  otherwise count as "inside".
- Stagger: each row carries transitionDelay = index * 45ms while opening and
  (count - 1 - index) * 45ms while closing, so the column unrolls upward and
  rolls back down.
- Rows stay mounted and are hidden with opacity-0 + translate-y-2 +
  pointer-events-none, plus tabIndex={-1} and aria-hidden while closed — so a
  closed menu costs no tab stops and announces nothing. Deliberately NOT
  visibility:hidden: focus() is silently dropped on a visibility-hidden element,
  and the frame on which it flips back is browser-scheduled (opening via Enter
  needs one more frame than a mouse click), so any "wait N frames then focus"
  scheme is a race. Opacity does not affect focusability, which is what lets the
  open effect focus the first row synchronously.
- prefers-reduced-motion: motion-reduce:transition-none on the rows, the glyph
  and the scrim — items appear and disappear instantly, and every interaction is
  unchanged.

Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground for the trigger,
  bg-card + border + shadow-md for the action circles with hover:bg-accent,
  bg-popover / text-popover-foreground for the label chips, bg-foreground/10 for
  the scrim, ring-ring for focus. No hardcoded colours.
- Layering: the column is absolute bottom-full mb-3 with flex-col-reverse (so
  DOM order equals tab order and index 0 renders nearest the trigger), z-50 for
  the column and trigger, z-40 for the scrim.
- a11y: the trigger carries aria-expanded + aria-controls, the column is a
  role="group" with the same accessible name, each circle carries aria-label
  (the label chip is aria-hidden so nothing is announced twice), and the glyph
  wrapper is aria-hidden.
- Merge consumer className via cn() so `relative` can be overridden by the
  caller's `absolute` / `fixed` positioning.

Customization levers
- Position: entirely className — fixed bottom-6 right-6 for an app shell,
  absolute inside a relative stage for an embedded canvas.
- Rhythm: STAGGER_MS (45) and the 200ms duration; keep total (count × stagger +
  duration) under ~400ms or the last action feels late.
- Direction: swap bottom-full/flex-col-reverse for top-full/flex-col to drop the
  column downward, or use flex-row for a horizontal dial.
- Density: the size-14 trigger / size-11 actions / gap-3 triple — shrink to
  size-12 / size-10 / gap-2 for desktop, keep 14/11 for touch.
- Labels: showLabels={false} for an icon-only dial (each button keeps its
  aria-label); the chip is a plain span, so it can carry a shortcut hint too.
- Scrim: overlay + the bg-foreground/10 value; raise it toward /30 and add
  backdrop-blur-sm for a modal-feeling menu on mobile. It is `fixed inset-0`, so
  it always covers the viewport, not the positioned box the FAB sits in — inside
  an embedded stage (a card, a preview frame) leave it off or swap fixed for
  absolute so it dims that box instead of the whole page.

Concepts

  • Speed dial — one always-present entry point expands into its own family of create actions, which is why the trigger glyph morphs (Plus→X) instead of being replaced by a separate close button.
  • Staggered reveal — a per-row transition-delay of index × 45ms makes the column read as one unrolling gesture; reversing the delay on close is what stops it from collapsing as an undifferentiated blob.
  • Opacity-gated rows — rows stay mounted for the transition but sit at opacity-0 + pointer-events-none + tabIndex={-1} + aria-hidden, so a closed menu contributes nothing to the tab order and nothing to announce. visibility: hidden would do the same job for sighted users but makes focus() a silent no-op until an unpredictable later frame — opacity keeps the row focusable the instant it opens.
  • Focus round trip — opening pushes focus to the nearest action, Escape and selection both push it back to the trigger; the keyboard journey always ends where it began.
  • Non-modal, so no focus trap — the page behind the column is never inert, so Tab has to be able to leave (WCAG 2.1.2); instead of cycling focus back into the menu, a focusout that lands outside the root closes it, which is also why the close is driven by focusout rather than by the Tab keydown that would unmount the still-focused row.
  • Two dismissal layers — the scrim closes through its own handler (it lives inside the root and would otherwise count as "inside"), while a document pointerdown listener, registered only while open, catches real outside clicks.
  • Positioning is the consumer's — the component owns direction (placement) but not page position; passing fixed or absolute through className is what lets the same widget be an app-shell FAB or an embedded canvas control.

On This Page