Bulk Action Bar
A floating toolbar that mounts once rows are selected — inline actions, an overflow "More" menu, and a live-announced count.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/bulk-action-bar.jsonPrompt
Build a React + TypeScript + Tailwind "BulkActionBar" component using
motion/react for enter/exit animation.
Contract
- BulkAction = { key, label, icon?, onAction: () => void, destructive?,
disabled? }.
- BulkActionBarProps = { count, total?, actions: BulkAction[], onClear,
onSelectAll?, maxVisibleActions = 3, position = "bottom" | "top" =
"bottom", anchor = "container" | "viewport" = "container", label =
"Bulk actions", className }.
- Plain function component (no forwardRef needed) — this is a compound
toolbar, not a single native-element wrapper.
Behavior
- Mount-driven visibility: the bar renders only while count > 0, wrapped in
<AnimatePresence>. On the 0→positive transition it slides + fades in
(translateY + opacity via motion.div initial/animate); on positive→0 it
plays the same transition in reverse and then fully unmounts — never a
hidden-but-focusable leftover for Tab to fall into.
- prefers-reduced-motion: read via motion/react's useReducedMotion() and
collapse the transition duration to 0 — the mount/unmount lifecycle still
runs (so cleanup still happens), it just happens instantly instead of
animating.
- No autofocus anywhere. Selecting rows must never steal focus away from
whatever the user is interacting with; the count change is communicated
via a persistent aria-live="polite" / role="status" span that lives
OUTSIDE the animated panel (so it survives every mount/unmount cycle and
reliably re-announces "N selected" / "N of M selected" each time).
- Esc is layered: a window keydown listener (added only while count > 0,
removed the instant it drops to 0) first checks whether the overflow menu
is open — if so, Esc closes just the menu and returns focus to its
trigger; only when the menu is already closed does Esc call onClear().
- maxVisibleActions counts the inline slots *including* the "More" trigger, so
actions past `maxVisibleActions - 1` collapse into that menu. Clamp the prop
to >= 1: at a cap of 1 the trigger alone fills the budget and every action
moves into the menu — still reachable. A cap of 0 (or negative) must not be
taken literally, or the bar would render a count with no way to reach any
action at all. Nothing is ever silently dropped.
- Count area shows "{count} selected", or "{count} of {total} selected"
once `total` is supplied. A "Select all {total}" text button appears only
when onSelectAll is provided AND total is provided AND count < total —
never when the caller can't express what "all" means.
Rendering & styling
- Semantic tokens only: bg-card + border + shadow-lg for the floating pill,
text-card-foreground for its text, text-muted-foreground for the Clear
button and dividers (bg-border), text-primary for the Select-all link,
text-destructive for destructive actions, focus-visible:ring-2
focus-visible:ring-ring on every interactive element. cn() merges the
consumer className onto the animated panel (not the positioning wrapper).
- anchor="container" (default): the wrapper is `absolute inset-x-0` —
requires the nearest ancestor to be `position: relative` (or similar).
anchor="viewport": wrapper is `fixed inset-x-0` instead. position="top" |
"bottom" picks which edge it docks to and which direction it slides from.
The wrapper itself is pointer-events-none (so the empty band around the
centered pill never blocks clicks); the pill switches back to
pointer-events-auto.
Customization levers
- maxVisibleActions: raise it to show more actions inline before anything
collapses into "More"; lower it (even to 1) to keep the bar narrow on
tight layouts.
- anchor / position: "container" + "bottom" suits a bar scoped to one list;
"viewport" + "top"/"bottom" suits an app-wide selection bar that should
survive scrolling past the list itself.
- Pill shape / dividers: the rounded-full shape and the two bg-border
divider spans are cosmetic — swap for rounded-lg or drop the dividers
without touching any behavior.
- label: overrides the accessible name exposed on the panel's role="group"
— set it to something specific ("12 emails selected") if a single generic
name isn't enough context for screen reader users.
- Icons on actions are optional — omit `icon` entirely for a text-only bar.Concepts
- Mount-driven visibility — the bar exists in the DOM only while
count > 0; there's no hidden-but-focusable remnant for Tab to land in once selection clears. - Layered Esc — a single Escape keypress closes an open overflow menu first; only a second press (with the menu already closed) clears the whole selection, so closing a submenu never destroys work-in-progress.
- Decoupled live region — the
aria-live="polite"announcement lives outside the animated panel, so it keeps firing correctly across every mount/unmount cycle instead of racing the panel's own entrance. - Overflow collapse, not truncation —
maxVisibleActionscaps the inline row; anything past that becomes a realrole="menu"with full keyboard support, never a silently-dropped action. - Cross-page select-all —
total+onSelectAllmodel "select everything matching the current filter," which is a different (and larger) set than "every row currently rendered."
Page Header
The app-shell page header — breadcrumb slot, icon, title, status meta, actions and an optional controlled tab strip, all responsive to a narrow container.
Virtual List
A hand-rolled windowed list — renders only the visible rows (+ overscan) of a huge array, with fixed or measured variable row heights, imperative scroll control, and infinite-scroll support.