Action Bar

The floating toolbar of bulk actions that appears while rows are selected — in a Data Table, a Data Grid or any checkbox list. It says how many items are selected, offers the commands that apply to all of them, and closing it clears the selection.

Import
@comitor/ui — the main entry. No optional peer to install.
Runtime
A client component. It portals into <body> after mount, so the server render and hydration never touch document; the callbacks (and labels.selected, a function) have to be written in a client component.
Origin
Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Button, tokens and strings. Every deviation from upstream is listed in the changelog.

Anatomy

Six parts, all from the main entry. The selection chip holds the count and the close button; the group holds the commands and behaves as one tab stop with arrow keys inside, the WAI-ARIA toolbar pattern.

anatomy.tsx
import {
  ActionBar,
  ActionBarClose,
  ActionBarGroup,
  ActionBarItem,
  ActionBarSelection,
  ActionBarSeparator,
} from '@comitor/ui'

<ActionBar open={selectedIds.length > 0} onOpenChange={onOpenChange}>  // role="toolbar", portalled to <body>
  <ActionBarSelection count={selectedIds.length}>                      // prints "3 selected"
    <ActionBarSeparator />                                              // decorative, aria-hidden
    <ActionBarClose />                                                  // X, named "Clear selection"
  </ActionBarSelection>
  <ActionBarSeparator />
  <ActionBarGroup>                                                      // ONE Tab stop, arrow keys inside
    <ActionBarItem onSelect={archive}>Archive</ActionBarItem>          // a Button: secondary, sm
    <ActionBarItem onSelect={remove}>Delete</ActionBarItem>
  </ActionBarGroup>
</ActionBar>

Bulk actions on a table

The job it was ported for. Select a few rows: the bar rises from the bottom of the window with a live count. Status opens a menu and keeps the selection, so you can apply more than one change; Archive moves the rows out and closes the bar; Delete asks first. Open the delete dialog or the status menu and press Escape — the menu closes, your selection stays.

TaskAssigneeStatusDue

Draft the Q4 pricing page

COM-231

Linh TrầnIn progressOct 3

Migrate invoices to the new ledger

COM-232

Marcus WebbTo doOct 6

Review the HR onboarding checklist

COM-233

Phạm Ngọc AnhTo doOct 8

Renew the Acme Corp contract

COM-234

Sofia ÁlvarezIn progressOct 10

Fix the CRM export encoding

COM-235

Nguyễn DuyDoneSep 30

Plan the customer advisory board

COM-236

Priya RamanTo doOct 14

Select rows with Space, then press Alt+Shift+Down Arrow to move focus into the bar.

The bar keeps no state of its own. open is derived from your selection, and everything that closes it reports through onOpenChange(false) — so that is the one place the selection is cleared. Three things call it:

  • Escape, pressed anywhere in the document while the bar is open.
  • ActionBarClose — the X in the selection chip.
  • Every ActionBarItem, after its onSelect runs. Call event.preventDefault() inside onSelect to keep the bar open: an item that opens a menu or a dialog must, or the selection is gone before the user has chosen anything.

Two details in the code are not decoration. The delete item is the trigger of the ConfirmDialog, so Cancel hands focus back to it instead of dropping it on the page. And restoreFocus covers what the package cannot: the bar unmounts the moment it closes, and since 3.0.1 it hands focus back to whatever had it just before focus entered the bar — usually the row checkbox the user came from. Archive and Delete remove that row, checkbox and all; the package does not guess, and without help a keyboard user lands on <body>. The demo picks the target itself a frame later, and focus the app moves always wins over the package’s. (In 3.0.0 no close handed focus back.)

Centred on its table

A bar in <body> centres on the viewport, and in an app frame with a sidebar that is not the middle of the table: it sits half the sidebar’s width to one side — 120px with a 240px sidebar — and the bar looks as if it has drifted. The first demo shows it on a wide screen: its bar lands left of the table by half this site’s sidebar. anchor="container" measures align and alignOffset from the portalContainer instead: the bar stays fixed at the bottom of the screen, but centres on that element and is never wider than it.

task-table.tsx
'use client'

import { useState } from 'react'
import { ActionBar, ActionBarClose, ActionBarSelection, ActionBarSeparator, DataTable } from '@comitor/ui'

export function TaskTable({ tasks }: { tasks: Task[] }) {
  const [selectedIds, setSelectedIds] = useState<string[]>([])
  // A callback ref in state, so the bar re-renders once the host exists.
  const [barHost, setBarHost] = useState<HTMLDivElement | null>(null)

  return (
    <div>
      <DataTable
        aria-label="Tasks"
        columns={TASK_COLUMNS}
        rows={tasks}
        getRowId={(task) => task.id}
        selectable
        selectedIds={selectedIds}
        onSelectedIdsChange={setSelectedIds}
      />
      {/* Empty, right after the table: as wide as the table, and next in the Tab order.
          It needs a box — with display: contents it would measure 0 × 0. */}
      <div ref={setBarHost} />

      <ActionBar
        open={selectedIds.length > 0}
        onOpenChange={(open) => !open && setSelectedIds([])}
        portalContainer={barHost}
        // Still fixed at the bottom of the screen — but centred on barHost, not the viewport.
        anchor="container"
      >
        <ActionBarSelection count={selectedIds.length}>
          <ActionBarSeparator />
          <ActionBarClose />
        </ActionBarSelection>
        {/* <ActionBarSeparator />, then your ActionBarGroup of items */}
      </ActionBar>
    </div>
  )
}
  • The host is an empty <div> right after the table: a block as wide as the table, and the next stop in the Tab order — which also answers the keyboard limitation below. It needs a box: with display: contents it measures 0 × 0.
  • The host is measured before the first paint and again whenever it or the window resizes, so the bar follows a collapsing sidebar frame by frame. align="start" and "end" count from the host’s edges, and the width cap becomes the smaller of the viewport minus 1rem and the host’s width. side still measures from the viewport.
  • Two limits, both from measuring in window coordinates. A host that moves without changing size is not measured again. And an ancestor that makes a containing block for fixed — a transform, a filter, contain — shifts the bar by that block’s own position. Leave anchor at its default there, and for an absolute bar in a pane (next section): left: 50% already measures from the right box.
  • New in 3.3.0. A hook that measured the table and overrode the bar’s style.left or maxWidth — the workaround before it — can go.

Scoped to a pane

In a split view — a mail list next to a reading pane — the bar belongs to the list, not the window. portalContainer moves its DOM into the pane, and className="absolute" swaps the built-in fixed (tailwind-merge keeps the last one), so side, align and the two offsets now measure from the pane’s edges — with anchor left at its default. Select a message or two, then try the controls.

side
align
  • Unread: Hana Suzuki

    Contract redlines for the Acme renewal

  • Unread: Trần Minh Khoa

    Payroll export for September

  • Olivia Bennett

    Re: onboarding checklist for new hires

  • Unread: Lê Thu Hà

    Invoice INV-2026-0918 is overdue

  • Daniel Okafor

    Q4 roadmap review — notes

  • The pane is the positioned ancestor and does not scroll; the list inside it does. Put the bar in the scrolling element and it scrolls away with the rows.
  • The pane is also isolate. Out of <body>, the bar’s z-50 competes with the page — scroll this one past the site’s own sticky header and, without isolation, it would paint on top of it.
  • The width cap is max-w-[calc(100vw-1rem)] — the viewport. Inside a pane, replace it with a cap on the pane, as above, or a long bar pokes out of a narrow column.
  • Pad the list by the bar’s height while it is open, on the side it sits, or it covers the last row you might want to select.
  • The bar now sits right after the list in the DOM, so Tab reaches it next — like the host <div> above, it avoids the keyboard limitation described below.

What the port guarantees

Behaviour you can rely on that the upstream component did not give you. Most of it is invisible in a screenshot and only shows up when you type or press Escape.

  • Escape that belongs to something else does not wipe the selection. Upstream listened for Escape on the document, so closing a Radix menu, popover or dialog above the bar also closed the bar — and cleared the rows the user had picked. The port ignores an Escape that is already defaultPrevented (Radix layers mark theirs) and one pressed while an input method is composing (isComposing — Telex or VNI through the OS input method, Japanese, Chinese). The package’s Tour marks its Escape as used too, so ending a tour leaves the selection alone.
  • Closing hands focus back. Upstream let the bar unmount with focus inside it, so Enter on an item, the X or Escape left a keyboard user on <body>, and the next Tab started again from the top of the page (WCAG 2.4.3). Since 3.0.1, a bar that closes with focus inside returns it to the element that had focus just before it entered the bar. Focus your app moves itself — in the handler, in an effect or later — wins, and an element that is no longer in the page is left alone.
  • It fits a phone, and uses a wide screen. Upstream had no width limit: measured at 375px, the bar was 413px wide and started at x = −19px, both ends clipped, and the Vietnamese count “Đã chọn 3” broke over three lines. The bar is now as wide as its content up to the viewport minus 1rem; past that, both its row and its group of items wrap, so every button stays on screen. It wraps rather than scrolls, because a scrolling strip would clip the focus ring of the buttons at its edges. The count chip never breaks. Before 3.3.0 a centred bar only ever got half the viewport — a fixed element sized to its content gets only the room after left: 50% — so it broke into two rows even on a wide screen, and at 375px its last button sat off-screen. Expect a lower, wider bar after upgrading, and drop any className="w-max" added to work around it.
  • Every control has a name. Upstream shipped no strings: an unnamed toolbar and an X button with no accessible name. The toolbar is now “Bulk actions” and the X “Clear selection”, both from the actionBar namespace, and count prints the selected line through the same table.
  • The close button is a 24 × 24 px target (WCAG 2.5.8), not a bare 14px icon — padding out and a negative margin back, so the chip does not grow. It uses the same muted-to-foreground treatment and offset focus ring as the Dialog close button.
  • Icons in items are sized. The package Button deliberately does not size child svgs, so a bare <Archive /> would render at 24px inside a 32px button. The item sizes unsized icons to 16px, the way DropdownMenuItem and CommandItem do.
  • It looks like the package’s other floating layers. bg-popover with shadow-overlay instead of upstream’s bg-card shadow-lg, whose Tailwind default shadow has no dark version.
  • Separators stay out of the accessibility tree — upstream set role="separator" on an element that was already aria-hidden, where no screen reader could reach it anyway.

Language

Three strings, in the actionBar namespace, and two of them are never on screen: the toolbar’s name and the close button’s name. They follow the nearest ComitorLocaleProvider — English by default, comitorMessagesVi.actionBar when the locale is Vietnamese. Set it once at the root, as Get started shows. Your item labels are your own text, so they are switched by your own i18n, as in the demo.

3
Toolbar name
Bulk actions
Close button name
Clear selection
selected(3)
3 selected

selected is a function of the count, because word order differs by language: “3 selected” in English, “Đã chọn 3” in Vietnamese. Concatenating it yourself is wrong in one of them. The English default avoids a noun on purpose; when your screen wants one, pass labels — a partial object is enough, and a prop beats the provider. DEFAULT_ACTION_BAR_LABELS is the English table since 3.0.0, and the bar no longer reads it directly; spread it when you build a full table of your own.

deals-bar.tsx
'use client' // labels.selected is a function: it cannot cross from a Server Component

import { ActionBar, ActionBarSelection } from '@comitor/ui'

// Partial is enough — the missing keys still come from the provider.
const DEAL_LABELS = {
  selected: (count: number) => (count === 1 ? '1 deal selected' : count + ' deals selected'),
}

export function DealsBar({ ids, onClear }: { ids: string[]; onClear: () => void }) {
  return (
    <ActionBar open={ids.length > 0} onOpenChange={(open) => !open && onClear()} labels={DEAL_LABELS}>
      <ActionBarSelection count={ids.length} />
      {/* … */}
    </ActionBar>
  )
}

// DEFAULT_ACTION_BAR_LABELS is the ENGLISH table (since 3.0.0).
// The Vietnamese one is comitorMessagesVi.actionBar, or getComitorMessages('vi').actionBar.

Props

ActionBar — plus every native div prop.

PropTypeDefaultDescription
openbooleanfalseWhether the bar is rendered at all. Controlled only: there is no defaultOpen and no internal state, so the bar closes only when you set this to false. The canonical wiring is open={selectedIds.length > 0}.
onOpenChange(open: boolean) => void—Called with false by Escape, by ActionBarClose and by every ActionBarItem whose onSelect does not call event.preventDefault(). Clear the selection here — closing the bar is how the user says they are done with it.
onEscapeKeyDown(event: KeyboardEvent) => void—Runs before an Escape closes the bar; call event.preventDefault() to keep it open — which marks the key as handled for every other listener too. Never called for an Escape that a menu, a dialog or a field already handled, or one pressed while an input method is composing.
side'top' | 'bottom''bottom'The edge the bar is pinned to — of the viewport, or of the containing block when you make it absolute.
sideOffsetnumber16Distance from that edge, in px.
align'start' | 'center' | 'end''center'Horizontal placement — on the viewport, on the portalContainer with anchor='container', or on the containing block when you make the bar absolute. start and end are the physical left and right edges: they do not swap under dir='rtl'.
alignOffsetnumber0Distance from the left edge (start) or the right edge (end) of whatever align measures from, in px. Ignored for center.
portalContainerElement | DocumentFragment | nulldocument.bodyWhere the bar is portalled. The fallback is read after mount, so the server render never touches document. This moves the DOM only: the bar stays position: fixed until you override that through className, and stays centred on the viewport unless anchor is 'container'.
anchor'viewport' | 'container''viewport'What align and alignOffset measure from (since 3.3.0). 'container' uses the portalContainer's box: the bar stays fixed at the bottom of the screen but centres on that element, and its width is capped at the smaller of 100vw − 1rem and the element's width. Measured before paint and again when the element or the window resizes. Without a portalContainer the two are the same.
orientation'horizontal' | 'vertical''horizontal'A wrapping row or a column. Also sets aria-orientation and picks the arrow keys: Left/Right, or Up/Down.
loopbooleantrueArrow keys wrap from the last item back to the first, and the other way round.
dir'ltr' | 'rtl'DirectionProvider, else 'ltr'Swaps Left and Right arrow navigation for right-to-left layouts.
labelsPartial<ActionBarLabels>provider's actionBar tabletoolbar (the bar's aria-label), selected(count) (the text ActionBarSelection prints) and close (the close button's name). A partial object merges over the provider's table.
aria-labelstringlabels.toolbarSpread after the built-in name, so it wins.
asChildbooleanfalseRender the toolbar onto your own element through Radix Slot.
className / stylestring / CSSProperties—Both merged last. className goes through tailwind-merge, so absolute replaces fixed. style is spread after the positioning styles, so it can override left, right, translate, the side offset or the max-width that anchor='container' sets — which, being inline, also beats a max-w-* class.

ActionBarSelection

PropTypeDefaultDescription
countnumber—Prints labels.selected(count) — “3 selected” — before children, which then only need to hold a separator and the close button. Ignored with asChild.
asChildbooleanfalseRender onto your own single child element; the count text is not added then.
...restReact.ComponentProps<"div">—The chip is whitespace-nowrap and shrink-0, so the count never breaks over two lines.

ActionBarItem — a package Button, with onSelect replacing the native event of the same name.

PropTypeDefaultDescription
onSelect(event: Event) => void—Runs on click, after onClick. The bar then closes unless you call event.preventDefault() — do that for anything that opens a menu or a dialog, or that should leave the selection in place.
variantButton variant'secondary'Every Button variant is accepted; the bar's own default is secondary.
sizeButton size'sm'32px tall. Unlike a plain Button, the item sizes an unsized child svg to 16px for you.
disabledbooleanfalseSkipped by the arrow keys and never becomes the tab stop.
...restButtonProps—Everything else a Button takes. onClick runs first; calling preventDefault() inside it cancels both onSelect and the close.

ActionBarClose — a native button.

PropTypeDefaultDescription
aria-labelstringlabels.close“Clear selection” by default. If you pass visible text as children, pass the same text here (WCAG 2.5.3), or aria-label={undefined}.
childrenReact.ReactNodeX iconA 14px X inside a 24 × 24 px target, padded outward and pulled back in so the chip does not grow.
onClick(event) => void—Runs before the close; call event.preventDefault() to stop it.
asChildbooleanfalseRender onto your own element; the X is not added then.

ActionBarSeparator. ActionBarGroup takes only div props and asChild: it is the roving-focus container, and it remembers the last focused item, so Tab returns there.

PropTypeDefaultDescription
orientation'horizontal' | 'vertical'the bar's orientationA 1px rule across the bar's direction; 16px tall inside the selection chip, 24px between groups. Always aria-hidden.

Known limitations

The first is recorded in the package’s port notes; the others follow from how the source is built. Each comes with its workaround.

  • Keyboard users reach it last. The bar is portalled to the end of <body>, so Tab gets there only after every other control on the page — in a 50-row table, after 50 checkboxes. On a screen people use a lot, add a shortcut that moves focus into it, as the first demo does with Alt+Shift+Down Arrow: scope it to the table, focus the ActionBarGroup element (it forwards to its tab stop), show the keys next to the table and declare them with aria-keyshortcuts. Or portal the bar next to the list — into an empty <div> after the table with anchor="container", or into a pane — at the price of inheriting that ancestor’s stacking context (and, for an absolute bar, its overflow clipping), which is exactly what <body> avoids.
  • Escape is heard page-wide. While the bar is open, any Escape that nothing else handled clears the selection — including one pressed in an unrelated search box, and one meant for a second bar elsewhere on the page. Let a field that uses Escape handle it and mark it with preventDefault(); the bar already skips a handled Escape. (Calling preventDefault() in the bar’s own onEscapeKeyDown keeps the bar open too, but it marks the key as used for every other listener as well — a native search field would no longer clear itself.)
    search-field.tsx
    import { Input } from '@comitor/ui'
    
    <Input
      type="search"
      aria-label="Search tasks"
      value={query}
      onChange={(event) => setQuery(event.target.value)}
      onKeyDown={(event) => {
        // The field owns this Escape. Marking it handled is what keeps the bar —
        // and the user's selection — out of it: the bar skips a defaultPrevented Escape.
        // An empty field lets it through, and the bar closes as usual.
        if (event.key === 'Escape' && query !== '') {
          event.preventDefault()
          setQuery('')
        }
      }}
    />
  • Focus is dropped when its way back is gone. There is no exit animation — the bar unmounts on the spot, taking the focused button with it — and the focus it hands back goes to the element that had focus before the bar did. When the action removed that element, as Archive and Delete remove the rows whose checkboxes they were, the package does not guess and focus falls to <body>. Keep a fallback of your own, as restoreFocus does above. (In 3.0.0 every close dropped focus.)
  • Nothing is announced. The bar is not a live region: neither its appearance nor the count is spoken when it changes. The checkboxes announce their own state; announce the result of a bulk action (“3 tasks archived.”) through a polite live region of your own.
  • align is physical. start is always the left edge, even under dir="rtl", where only the arrow keys swap. Pass end yourself in a right-to-left layout.

Accessibility

KeyDoes
Tab / Shift+TabMoves between the close button and the item group. The group is one tab stop, whatever its size.
← / →Moves between items (↑ / ↓ when vertical). Wraps round with loop; swapped under dir='rtl'.
Home / EndFirst / last enabled item.
Enter / SpaceActivates the item — onSelect runs, then the bar closes unless onSelect prevented it.
EscapeCloses the bar — anywhere in the document, not only inside the bar. See the port guarantees.
Alt+Shift+↓Not built in: the shortcut the demo above adds, to reach the bar from the table.
  • The root is role="toolbar" with aria-orientation and an aria-label from labels.toolbar (“Bulk actions”). The group is role="group" and implements roving focus: one tab stop, arrow keys inside, and it remembers the item you left.
  • The close button is named by labels.close (“Clear selection”), because closing the bar clears the selection — the name says what happens, not what the icon looks like.
  • Opening the bar does not move focus into it, and that is right: the user is usually still selecting. Give them a way in (a shortcut, or DOM order). The way back out is built in since 3.0.1 — closing with focus inside returns it to where it came from — except after an action that removed that element; cover that case yourself.
  • No live announcements come from the bar. Announce outcomes yourself — the demo’s status line under the table is an aria-live="polite" paragraph that is always in the DOM, so the change is heard.
  • Item icons are decoration: mark them aria-hidden and keep a visible text label. An icon-only item needs an aria-label.
  • Destructive items go through a ConfirmDialog — a bulk delete removes many records with one keystroke, and the confirm is the only undo the user gets.
  • Items are package Buttons, so the focus ring is the Button’s outline ring; the close button has its own ring-2 with an offset, like the Dialog close button.
  • The bar rises in over 250 ms (fade, slight zoom, 16px slide) and does so only without prefers-reduced-motion; with it, the bar simply appears. Only the entrance moves: a change of position, such as the anchor being measured again, applies at once. Before 3.3.0 every change of position slid for 250 ms.

Related

  • Data Table and Data Grid — the selection this bar acts on.
  • Tasks, CRM and Email patterns — bulk actions on real screens: bars centred on their grid with anchor="container", and one scoped to a mail list’s pane.
  • Confirm Dialog for destructive items, and Dropdown Menu for items that choose a value.
  • Keyboard Hint — the platform-aware key labels used for the shortcut above.