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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/fab-menu.jsonPrompt
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-delayofindex × 45msmakes 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: hiddenwould do the same job for sighted users but makesfocus()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
focusoutrather 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
pointerdownlistener, registered only while open, catches real outside clicks. - Positioning is the consumer's — the component owns direction (
placement) but not page position; passingfixedorabsolutethroughclassNameis what lets the same widget be an app-shell FAB or an embedded canvas control.