Skip to content

Action bar

The floating bar that rises when rows are selected, with the count and bulk actions.

Status
Beta
Category
Actions
Adoption
Not used yet
import { ActionBar } from "@oration/canon/components/action-bar";
packages/canon/src/components/action-bar.tsx
  • INV-20931Northwind FreightDue Oct 2$18,240.00
  • INV-20944Halcyon HealthDue Oct 5$4,600.00
  • INV-20952Orchard Street MarketDue Oct 6$2,115.50
  • INV-20960Meridian Parts Co.Due Oct 9$9,870.00
import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, DownloadIcon } from "lucide-react";import * as React from "react";export function Hero() {    const invoices = [        {            id: "INV-20931",            supplier: "Northwind Freight",            due: "Oct 2",            amount: "$18,240.00",        },        {            id: "INV-20944",            supplier: "Halcyon Health",            due: "Oct 5",            amount: "$4,600.00",        },        {            id: "INV-20952",            supplier: "Orchard Street Market",            due: "Oct 6",            amount: "$2,115.50",        },        {            id: "INV-20960",            supplier: "Meridian Parts Co.",            due: "Oct 9",            amount: "$9,870.00",        },    ];    const [selection, setSelection] = React.useState<Set<string>>(        () => new Set(["INV-20931", "INV-20952"]),    );    const toggle = (id: string, checked: boolean) =>        setSelection((current) => {            const next = new Set(current);            if (checked) next.add(id);            else next.delete(id);            return next;        });    return (        <div className="relative h-[19rem] w-full max-w-2xl overflow-hidden rounded-xl bg-card shadow-border">            <ul                aria-label="Invoices due this week"                className="divide-y divide-border text-13"            >                {invoices.map((invoice) => {                    const selected = selection.has(invoice.id);                    return (                        <li                            key={invoice.id}                            className={cn(                                "flex h-11 items-center gap-3 px-4 transition-colors duration-100",                                selected &&                                    "bg-[color-mix(in_oklch,var(--primary)_6%,var(--background))]",                            )}                        >                            <Checkbox                                aria-label={`Select ${invoice.id}`}                                checked={selected}                                onCheckedChange={(checked) =>                                    toggle(invoice.id, checked)                                }                            />                            <span className="w-24 font-mono text-xs text-muted-foreground">                                {invoice.id}                            </span>                            <span className="min-w-0 flex-1 truncate font-medium">                                {invoice.supplier}                            </span>                            <span className="text-muted-foreground max-sm:hidden">                                Due {invoice.due}                            </span>                            <span className="w-24 text-right tabular-nums">                                {invoice.amount}                            </span>                        </li>                    );                })}            </ul>            <ActionBar                count={selection.size}                noun="invoices selected"                onClear={() => setSelection(new Set())}            >                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() => {                        toast.add({                            type: "success",                            title: `${selection.size} invoices approved`,                        });                        setSelection(new Set());                    }}                >                    <CheckIcon data-icon="inline-start" aria-hidden="true" />                    Approve                </Button>                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() =>                        toast.add({                            title: `Exported ${selection.size} invoices`,                        })                    }                >                    <DownloadIcon data-icon="inline-start" aria-hidden="true" />                    Export                </Button>            </ActionBar>        </div>    );}

Usage#

Action bar is the floating toolbar that rises from the bottom of a list or grid while rows are selected. It says how many records are selected, offers the two to four bulk actions that apply to all of them, and clears the selection from its trailing close button. It is absolutely positioned against its nearest positioned ancestor, so the common mistake is mounting it in a container without relative, where it floats to the bottom of the page instead of the list.

When to use

  • When one or more rows in a data grid, table or list are selected and the same action can apply to all of them: Approve, Export, Add to payment run.
  • To show the selection count while the selection is off screen, so people can scroll and keep selecting.
  • To group bulk actions that would otherwise crowd the page header, with overflow in a More menu.
  • As the one place selection can be cleared in a single click, beside the actions it affects.

When not to use

  • For unsaved form changes with Discard and Save. That bar has a different job and keyboard shortcut. Use Save bar
  • For actions on selected text inside an editor or a message. Use Selection actions
  • For actions that are always available regardless of selection, such as filters, sort and view options. Use Toolbar
  • For a single record's actions. Put them in a row menu or the record header. Use Dropdown menu
  • To confirm a destructive bulk action. The bar starts it; the confirmation lives in a dialog. Use Confirm dialog

The One Filled Button Rule

Actions inside the bar are ghost at sm. The bar is already the most elevated thing on the screen; a filled button inside it competes with the page's own primary action.

The Quiet Indigo Rule

Selection is one of the few places indigo is spent: selected rows take the 6% indigo tint. The bar itself stays Popover White with ink text and never takes an indigo fill.

Destructive is a tint

A destructive bulk action is a ghost button with Signal Red text, last before the close button, and it opens a confirmation instead of acting immediately.

Anatomy#

3 selectedApproveExport
  1. Count. {count} {noun} in 13px medium tabular figures, so the width stays steady as the number changes.
  2. Separator. A 20px hairline (bg-border) between the count, the actions and the close button.
  3. Actions. Your children: ghost sm buttons, dropdown triggers or other compact controls, 4px apart.
  4. Clear selection. A ghost icon-sm button with an X and aria-label="Clear selection" that calls onClear.
  5. Surface. Popover White, 12px container corners, 6px padding and the floating bar shadow (shadow-popover), centered 20px above the bottom of its container.

Examples#

Enter and exit

The bar rises on a spring when count goes above zero and drops away faster when it returns to zero. With no noun, the count reads 4 selected.

import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Motion() {    const [count, setCount] = React.useState(0);    React.useEffect(() => {        const id = window.setTimeout(() => setCount(4), 400);        return () => window.clearTimeout(id);    }, []);    return (        <div className="relative flex h-36 w-full max-w-2xl items-start justify-center rounded-xl bg-muted/70 p-4">            <Button                type="button"                variant="outline"                size="sm"                onClick={() => setCount((c) => (c > 0 ? 0 : 4))}            >                {count > 0 ? "Clear selection" : "Select 4 rows"}            </Button>            <ActionBar count={count} onClear={() => setCount(0)}>                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() =>                        toast.add({ title: "Exported 4 remittances" })                    }                >                    Export                </Button>            </ActionBar>        </div>    );}

With a menu

A dropdown trigger works as an action. Open it with side="top" so the menu stays on screen above the bar.

import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuGroup,  DropdownMenuItem,  DropdownMenuLabel,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { CalendarClockIcon, UserPlusIcon } from "lucide-react";import * as React from "react";export function WithMenu() {    const [count, setCount] = React.useState(12);    const approvers = ["Jordan Lee", "Priya Raman", "Aisha Bello"];    return (        <div className="relative flex h-44 w-full max-w-2xl items-start justify-center rounded-xl bg-muted/70 p-4">            <Button                type="button"                variant="outline"                size="sm"                onClick={() => setCount((c) => (c > 0 ? 0 : 12))}            >                {count > 0 ? "Clear selection" : "Select 12 invoices"}            </Button>            <ActionBar                count={count}                noun="invoices selected"                onClear={() => setCount(0)}            >                <DropdownMenu>                    <DropdownMenuTrigger                        render={                            <Button type="button" variant="ghost" size="sm" />                        }                    >                        <UserPlusIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Assign approver                    </DropdownMenuTrigger>                    <DropdownMenuContent                        align="center"                        side="top"                        className="w-52"                    >                        <DropdownMenuGroup>                            <DropdownMenuLabel>Approvers</DropdownMenuLabel>                            {approvers.map((name) => (                                <DropdownMenuItem                                    key={name}                                    onClick={() =>                                        toast.add({                                            title: `${count} invoices assigned to ${name}`,                                        })                                    }                                >                                    {name}                                </DropdownMenuItem>                            ))}                        </DropdownMenuGroup>                    </DropdownMenuContent>                </DropdownMenu>                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() =>                        toast.add({                            title: `${count} invoices added to the Oct 2 run`,                        })                    }                >                    <CalendarClockIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Add to run                </Button>            </ActionBar>        </div>    );}

Destructive action

Put the destructive action last, as a ghost button with red text. In the product it opens a confirmation before anything is removed.

import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Trash2Icon } from "lucide-react";import * as React from "react";export function Destructive() {    const [count, setCount] = React.useState(3);    return (        <div className="relative flex h-40 w-full max-w-2xl items-start justify-center rounded-xl bg-muted/70 p-4">            <Button                type="button"                variant="outline"                size="sm"                onClick={() => setCount((c) => (c > 0 ? 0 : 3))}            >                {count > 0 ? "Clear selection" : "Select 3 suppliers"}            </Button>            <ActionBar                count={count}                noun="suppliers selected"                onClear={() => setCount(0)}            >                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() =>                        toast.add({ title: "W-9 requests sent to 3 suppliers" })                    }                >                    Request W-9                </Button>                <Button                    type="button"                    variant="ghost"                    size="sm"                    className="text-destructive hover:text-destructive"                    onClick={() => {                        toast.add({                            title: "3 suppliers archived",                            description:                                "Open invoices from them stay in the queue.",                        });                        setCount(0);                    }}                >                    <Trash2Icon data-icon="inline-start" aria-hidden="true" />                    Archive                </Button>            </ActionBar>        </div>    );}

States#

2 selectedApprove
Hidden
2 selectedApprove
Entering
2 selectedApprove
Shown
2 selectedApprove
Exiting
import { cn } from "@oration/canon/lib/utils";import { XIcon } from "lucide-react";export function StatesMatrix() {    const stages = [        { label: "Hidden", className: "opacity-0 translate-y-3 scale-[0.98]" },        {            label: "Entering",            className: "opacity-60 translate-y-1.5 scale-[0.99]",        },        { label: "Shown", className: "" },        {            label: "Exiting",            className: "opacity-40 translate-y-1 scale-[0.98]",        },    ];    return (        <div className="grid w-full gap-4 sm:grid-cols-2">            {stages.map((stage) => (                <div                    key={stage.label}                    className="flex flex-col items-center gap-2"                >                    <div className="flex h-16 w-full items-center justify-center rounded-[10px] bg-muted/70">                        <div                            className={cn(                                "flex items-center gap-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-popover",                                stage.className,                            )}                        >                            <span className="px-2 text-13 font-medium tabular-nums">                                2 selected                            </span>                            <span                                aria-hidden="true"                                className="mx-0.5 h-5 w-px bg-border"                            />                            <span className="px-2 text-[0.8rem] font-medium">                                Approve                            </span>                            <span                                aria-hidden="true"                                className="mx-0.5 h-5 w-px bg-border"                            />                            <XIcon                                aria-hidden="true"                                className="mx-1.5 size-3.5"                            />                        </div>                    </div>                    <span className="text-xs text-muted-foreground">                        {stage.label}                    </span>                </div>            ))}        </div>    );}
States
StateTreatment
HiddenWhile count is 0 nothing renders, so the bar adds no DOM and no tab stops.
EnteringWhen count goes above 0 the bar fades in and rises from 12px below at a 0.98 scale on a 300ms spring with no bounce.
ShownFull opacity at rest. The count updates in place as rows are added or removed; the bar does not re-animate.
ExitingWhen count returns to 0 it fades and drops 8px at a 0.98 scale over 140ms, faster than it entered.
Action hover and focusEach action is a Button, so it takes the ghost hover fill and the indigo focus ring.
Menu openA dropdown trigger inside the bar keeps its hover fill while its menu is open. Open menus upward with side="top" so they stay in view.

Behavior#

  • Visibility is driven entirely by count. Pass the size of your selection; there is no open prop.
  • The bar is absolute bottom-5 left-1/2 -translate-x-1/2 z-40. Give the scrolling list's wrapper relative and enough bottom padding (about 72px) that the last row can scroll clear of the bar.
  • pointer-events-auto is set on the bar, so it stays clickable inside a pointer-events-none overlay layer.
  • The close button only calls onClear. Clearing the selection is your job, and so is clearing it after a bulk action finishes.
  • Enter and exit run through Motion's AnimatePresence. The component doesn't read prefers-reduced-motion itself; the app-wide MotionConfig reducedMotion="user" drops the rise and scale and keeps the fade.
  • Actions are your own components, so they can open a menu, a dialog or a confirm. Dropdown menus portal to the body and are not clipped by the list.

Do and don't#

2 selected
Do. Use ghost sm buttons for every action in the bar.
2 selected
Don't. Fill the actions. Three primary buttons in a floating surface outshout the page they act on.
8 invoices selected
Do. Keep two or three direct actions and move the rest into a More menu.
8 selected
Don't. Line up every bulk action. The bar grows past the list and gets clipped on narrow screens.

Content#

  • Name what is selected through noun: 12 invoices selected, not 12 selected when the list mixes kinds of record.
  • Handle the singular yourself. noun is printed as given, so pass invoice selected when count is 1.
  • Action labels are short verbs, often without the object, since the count already names it: Approve, Export, Archive.
  • After a bulk action, confirm with a toast that repeats the count and the outcome: 8 invoices approved.
  • For a destructive action, put the count in the confirm dialog title: Archive 3 suppliers?

Accessibility#

  • The bar is role="toolbar" with aria-label="Bulk actions". Each child is a separate tab stop; there is no arrow-key roving between actions.
  • Nothing announces the bar when it appears. The selected rows' checkboxes already announce their checked state, so people learn about the selection from there.
  • Clear selection is an icon-only button with an accessible name. It has no tooltip.
  • Mark icons inside actions aria-hidden="true"; the labels name the actions.
  • Make sure the bar does not cover the focused row. Bottom padding on the list keeps the last row reachable.
  • Under reduced motion the bar fades in place without rising. Keep its appearance obvious without the animation.
Keyboard interactions
KeysAction
TabMoves through the actions in order, then the clear button. The bar sits where it is mounted in the DOM, usually after the list.
EnterRuns the focused action.
SpaceRuns the focused action.

Design tokens#

Design tokens
TokenUsed for
--popoverBar background
--popover-foregroundCount and action text
shadow-popoverThe floating bar shadow
--borderThe hairline separators
rounded-xl12px container corners
text-13The count, medium weight, tabular figures
--primaryThe 6% tint on selected rows beneath it (yours, not the bar's)
--destructiveText of a destructive ghost action

API reference#

ActionBar

A floating bulk-action toolbar. Renders nothing while count is 0.

Other props spread onto Nothing. Only the props below are accepted..

Props of ActionBar
PropTypeDefaultDescription
countRequirednumberNo defaultHow many records are selected. The bar shows while it is above 0.
nounstring"selected"Printed after the count, as in 12 invoices selected. Not pluralized for you.
onClearRequired() => voidNo defaultCalled by the trailing clear button.
childrenRequiredReact.ReactNodeNo defaultThe actions, between the count and the clear button. Ghost sm buttons.
classNamestringNo defaultMerged onto the bar, for example to change bottom-5 or z-40.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

No reduced-motion handling of its own. The app-wide MotionConfig drops the rise and scale, but the 300ms spring isn't one of the three tiers.

The bar is role="toolbar" but has no roving focus or arrow-key navigation, which the toolbar pattern implies.

The clear button has an aria-label but no tooltip, unlike other icon-only buttons in the suite.

There is no destructive ghost action. Four product bars (tables, deals, companies, people) pass text-destructive hover:text-destructive to a ghost Button instead.

noun is not pluralized. The tickets bar switches between ticket and tickets by hand; others use the default and print 12 selected.

The aria-label is fixed to Bulk actions and cannot be changed, and no other props (such as id or data-*) are forwarded.