Skip to content
txbrendan/ui

Bulk Bar

beta

The bar that rises while rows are selected, with the count and the verbs that work on all of them. Its keys work while it shows, and Esc clears the selection.

Preview

Pick people. The bar rises with R and A working, and Esc clears.

Last action: Nothing yet

in flow

danger

states

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/bulk-bar

Use it for

  • A list or table where people pick rows, then act on all of them at once, like Remind or Mark available.
  • Pass the same action objects your rows give HoverActions, so each verb works on one row or many.
  • Use floating over a long list, inside a parent with position: relative. Leave room at the bottom so it never hides the last row.
  • To place it yourself, like sticky at the bottom of a long page, leave floating off and wrap it in your own box.
  • Give an action a result when its press changes something, like 'Sent to 3', so the button says what happened, with Undo.

Avoid it for

  • A single row's actions. Use HoverActions or RowActions.
  • More than three or four verbs. Put the rest in a Menu.
  • A bar that shows with nothing selected. It leaves at count 0 on its own.

Props

  • count

    Required. How many rows are selected. At 0 the bar goes away.

    number

  • noun

    The selected thing, singular and plural, like ['person', 'people'].

    [string, string] · default ["item", "items"]

  • actions

    The verbs for the selection. Each shows its icon and label, or its icon only when the labels do not fit on one line. A shortcut works while the bar shows, and the tooltip teaches it.

    BulkBarAction[] · default []

  • BulkBarAction

    Type. The same shape as HoverAction. A promise from onClick shows a spinner, and a failure shows Retry. result is ToolButton's ActionResult.

    { label: string; icon?: ReactNode; shortcut?: string; tone?: "neutral" | "danger"; onClick?: () => void | Promise<unknown>; result?: ActionResult; disabled?: boolean }

  • onClear

    Clears the selection. It adds the clear button, and Esc calls it too.

    () => void

  • floating

    Floats 1rem above the bottom of the nearest positioned parent, centered. Otherwise it sits in the flow, centered, and keeps its space.

    boolean · default false

  • keys

    Listens for the actions' keys and Esc while the bar shows.

    boolean · default true

  • clearLabel

    Names the clear button.

    string · default "Clear selection"

  • ref

    Reaches the toolbar.

    Ref<HTMLDivElement>

  • className

    Extra classes on the toolbar. Later classes win.

    string

Accessibility

  • The bar is a toolbar named by its count, like '2 people selected'. It is one Tab stop, and the arrow keys move between its buttons.
  • The count sits in a polite live region, so screen readers hear it change.
  • While it is away, the bar is inert. It leaves the Tab order and the accessibility tree, and it ignores the pointer.
  • If focus is inside when the bar leaves, focus goes back to where it came from.
  • When the labels do not fit on one line, the actions show their icons only. Each keeps its name, and its tooltip shows the label and key.
  • Keys are ignored while someone types, while a modifier is held, and inside an open dialog or menu. A control marked data-owns-escape keeps its Esc.
  • The clear button sets aria-keyshortcuts to Escape, and each action with a single-key shortcut sets its own.

Examples

Over a list

import { BellRing, ThumbsUp } from "lucide-react";
import { useState } from "react";

function People({ names }: { names: string[] }) {
  const [picked, setPicked] = useState<string[]>([]);
  return (
    <div className="relative pb-16">
      {names.map((name) => (
        <label key={name} className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={picked.includes(name)}
            onChange={(event) => setPicked((last) => (event.target.checked ? [...last, name] : last.filter((n) => n !== name)))}
          />
          {name}
        </label>
      ))}
      <BulkBar
        floating
        count={picked.length}
        noun={["person", "people"]}
        onClear={() => setPicked([])}
        actions={[
          { icon: <BellRing />, label: "Remind", shortcut: "R", result: { label: `Sent to ${picked.length}` } },
          { icon: <ThumbsUp />, label: "Mark available", shortcut: "A", onClick: () => setPicked([]) },
        ]}
      />
    </div>
  );
}