Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Action bar

New

Floating bars for bulk actions, a plan notice and undo toasts, whose Delete morphs the bar into its own undo toast.

action-bar/action-bar.tsx
Live · interactive
3 of 200 selected
3 rows have been deleted
Your free plan ends on October 17th, 2024
Amount range filter has been deleted
Vela schedule has been successfully canceled

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Rows picked in a table or a list, and a few things to do to all of them at once: move, copy, export, delete.
  • A bar that floats over the content only while there is a selection, so the page keeps its toolbar for everything else.
  • An action that is easy to take back: delete with an Undo in a toast, rather than a dialog asking first.
  • A short notice about the account or the plan, with one way forward, that should stay in view without blocking anything.

When not to use

  • One row at a time. Put its actions on the row, or in a menu at its end.
  • More than four or five bulk actions. Keep the common ones on the bar and the rest under a More menu, or use a toolbar.
  • A deletion that can’t be undone. Ask first in a confirmation that names what goes and how many.
  • Errors that need fixing before going on. Show them by the field or in the page, not in a toast that leaves.
  • News that matters for a while (billing, outages). Use a banner across the page that stays until it is dealt with.

You can add this component with the shadcn CLI or by hand:

Into components/action-bar of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/action-bar.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

table-actions.tsx
import { ActionBar } from './action-bar';export function TableActions({ count, onDelete }: { count: number; onDelete: () => void }) {  return <ActionBar autoplay={false} selected={count} defaultTotal={200} onDelete={onDelete} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Four bars stacked on a sheet of dashed guides: a bulk-action bar for the rows selected in a table, a notice about the plan, and two toasts that offer to undo. Change status opens a menu upward, while Duplicate and Export confirm with a check. Delete reshapes the bar into a toast that offers to undo it, and the row count rolls digit by digit.

  • Four bars sit on a sheet whose dashed guides are the bars’ own edges drawn across it. By day each bar is white with a hairline; at night it has a near-black rim with a faint light line inside it, brighter along the top.
  • Change status opens a small menu upward from the bar, driven by the arrow keys, Home, End and Escape. Duplicate and Export swap their icon for a check for a moment.
  • Duplicating adds rows, and the total rolls to its new value: each digit is a column of 0 to 9 sliding in a window one digit tall.
  • Delete turns the bar into a toast. Its rim, light line and fill are fixed-size layers wider than both shapes, each cut to the bar by a clip-path that springs from the old width to the new one (FLIP), while the two rows cross-fade. Undo plays it back.
  • A toast’s Undo sends it away on a short fall, and on this demo stage it springs back a moment later. Each bar paints on a small layer of its own inside one fixed-height layout boundary; below 650px the bulk bar keeps its icons only and the messages wrap.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: white bars with a soft shadow on a light grey sheet by day, near-black bars on a dark grey sheet a step above them at night, so the bars keep their shape in both. Explore plans is the one filled button, black by day and white at night.
PropTypeDefaultDescription
selectednumber3How many rows are selected.
defaultTotalnumber200How many rows there are at first.
defaultStateActionBarState—Opens in a given moment, with the demo off: the status ticked and whether its menu is open, the actions showing their check, whether the rows were deleted, the toasts sent away by Undo (filter, schedule) and the total. Checks and toasts given here stay put.
onStatusChange(status: StatusId) => void—Called with the status picked in the menu (backlog, progress, done or canceled).
onDuplicate() => void—Called when the selected rows are duplicated.
onExport() => void—Called when the selected rows are exported.
onDelete() => void—Called when the selected rows are deleted.
onRestore() => void—Called when the deletion is undone.
onExplorePlans() => void—Called when Explore plans is pressed.
autoplaybooleantrueWhile on screen and untouched, a ghost pointer picks a status, duplicates, exports, deletes, restores, undoes both toasts and resets.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

action-bar.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type FocusEvent,  type KeyboardEvent,  type ReactNode,  type Ref,} from 'react';import { DemoPointer, runDemo } from './demo-pointer';import {  AlertIcon,  CheckIcon,  ChevronUpIcon,  DuplicateIcon,  ExportIcon,
action-bar.module.css
/* Action bar (action-bar.tsx). A sheet with four floating bars stacked in its middle, and the   bars' edges drawn as dashed guides across it. In a stage narrower than the bulk bar, the bulk   bar keeps its icons only and the messages wrap.   Colours. Every colour is a token on .root, light-dark(light, dark): the component takes the   color-scheme it inherits from the page (light unless the page says otherwise), or the one its   colorScheme prop sets (data-color-scheme). By day the bars are white, with a hairline and a soft   shadow, on a light grey sheet; at night they are near-black, with a dark rim and a faint light   line inside it, on a dark grey sheet.   Memory and layout. The stage is one layout boundary of a known size. What moves is a transform,   an opacity or a clip-path on a box of a fixed size: the plate behind the bulk bar never changes   size (a clip-path cuts it to the bar's width), the toasts move as a whole, the digits slide in   their windows. The icons are drawings that never change; the bars fade the boxes around them. */.root {  /* The sheet, its edge, and the dashed guides across it. The sheet is a step off the bars: a     light grey under white bars by day, a dark grey above near-black ones at night. */  --surface: #f5f5f7;  --edge: rgb(0 0 0 / 0.06);  --guide: #dbdbe0;  /* The bars' shadow on the sheet: close, then softer further down. */  --shadow: rgb(0 0 0 / 0.08);  --shadow-far: rgb(0 0 0 / 0.07);
rolling-number.tsx
import styles from './action-bar.module.css';/* * A number whose digits roll to their new values, like an odometer. Each digit is a column of * 0 to 9 seen through a window one digit tall; a change slides the column (a transform, on a * spring set in the stylesheet). The digits are drawn with tabular figures, so the number keeps * its width while they roll. Screen readers get the plain number from the bar's own label. */const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];export function RollingNumber({ value }: { value: number }) {  const digits = String(Math.max(0, Math.round(value))).split('');  return (    <span className={styles.rolling} aria-hidden="true">      {digits.map((digit, i) => (        // Keyed from the right, so a number that grows a digit keeps its ones in place.        <span key={digits.length - i} className={styles.digit}>          <span className={styles.digitSize}>0</span>          <span            className={styles.column}            style={{ transform: `translateY(${-Number(digit) * 10}%)` }}          >            {DIGITS.map((d) => (
icons.tsx
import type { ReactNode } from 'react';/* * The bars' icons: 16 × 16 line drawings with a 1.5 stroke and round ends, in the colour of the * text around them. They never change once drawn (a style change inside an SVG lays it out * again): to switch one for another, the bar fades the HTML boxes around them. */function Icon({ children }: { children: ReactNode }) {  return (    <svg      viewBox="0.6 0.6 14.8 14.8"      width="16"      height="16"      fill="none"      stroke="currentColor"      strokeWidth="1.5"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"    >      {children}    </svg>  );
spring.ts
/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = {  /** The curve, as a CSS `linear()` easing. */  easing: string;  /** How long it takes to settle, in ms: the duration to play the curve over. */  duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a control and press it, calling the component's own handler as a click * would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress * at once: its promise rejects, which ends the script right there, and the pointer fades out. * * Nothing the pointer does needs a layout: it glides by an animation the compositor plays, and * the press scales a box that keeps a transform at rest (a box that gains or loses one is laid * out again). The arrow is an SVG drawing whose style never changes, inside HTML boxes. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Change status is a menu button (aria-haspopup, aria-expanded) whose statuses are radio menu items, reached with the arrow keys, Home and End; Escape closes the menu and returns focus to the button.
  • Screen readers get the count as plain text (“3 of 200 selected”); the rolling digits are hidden from them.
  • Every action the reader takes is announced in a polite live region. Delete moves focus to the toast’s Undo, and Undo moves it back to Delete.
  • The hidden row and the toasts that are away are inert. Under reduced motion every change is immediate and the demo never plays; keyboard focus inside the stage also stops the demo.