Display

Bulk Action Bar

A floating toolbar that mounts once rows are selected — inline actions, an overflow "More" menu, and a live-announced count.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Overflow menu entrance only (the bar itself is animated by motion/react).
 * Ships via a React 19 hoisted <style href> — duplicates dedupe by href.
 */
const KEYFRAMES = `@keyframes bab-menu-in{from{opacity:0;transform:scale(0.96) translateY(4px)}to{opacity:1;transform:none}}`

export interface BulkAction {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/bulk-action-bar.json

Prompt

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 truncationmaxVisibleActions caps the inline row; anything past that becomes a real role="menu" with full keyboard support, never a silently-dropped action.
  • Cross-page select-alltotal + onSelectAll model "select everything matching the current filter," which is a different (and larger) set than "every row currently rendered."

On This Page