Skip to contentVibraUI
Navigation & layout

Action bar

A floating bar for unsaved changes or a bulk selection, with the actions that resolve it.

A client component. It draws nothing while closed and slides in when it opens; only its status line, empty and visually hidden, stays in the page, so the first message is heard as surely as the next. Both bars are sticky inside the content flow rather than fixed to the viewport, which is what keeps them clear of the sidebar at every width and in every collapse state: render one at the end of the page content for a bottom bar, at the start for a top one, and pass fixed through className if you want the viewport behaviour instead. Both are labelled regions whose message is spoken by that status line, which sits just before the region (a live region created with its words already in it is often not read out), so a bar that appears mid-edit, or a count that ticks up, is read out without moving focus — and said once, since the words inside the bar are plain text. Two bars docked to the same edge will overlap once both are stuck — give one an offset such as bottom-20, or show one at a time. This is the one place in Vibra with a real shadow: it has to read as floating above the page it edits.

Install

npx shadcn@latest add @vibra/action-bar

Needs the @vibra registry in your components.json — set it up once.

Examples

Selection

The bulk-action bar, counting the rows picked and offering what to do with them.

Props

PropTypeDefaultDescription
openboolean—Whether there is anything to save; false draws nothing but the empty status line the first message is spoken from.
messageReact.ReactNodeYou have unsaved changes.What is unsaved, shown at the start of the bar.
onSave() => void | Promise<void>—Runs from the save button; drive saving from your own mutation state.
onDiscard() => void—Throws the edits away and closes the bar.
savingbooleanfalseLocks both buttons and spins the save button.
saveTextstringSave changesThe save button's label; keep the verb the same one the toast uses.
discardTextstringDiscardThe discard button's label.
position"bottom" | "top""bottom"Which edge the bar docks to, and the direction it slides in from.
SelectionActionBar.countnumber—How many rows are selected; the bar disappears at zero, and its empty status line stays so the first pick is heard.
SelectionActionBar.onClear() => void—Empties the selection from the bar's clear button.
SelectionActionBar.childrenReact.ReactNode—The bulk actions, usually two or three buttons.
SelectionActionBar.itemLabelstringitemSingular noun for what is selected; the plural gets an s.

Dependencies

Source

components/ui/action-bar.tsx
"use client"

import * as React from "react"
import { Loader2Icon, XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

// Sticky inside the content flow rather than fixed to the viewport: a viewport-
// centred bar draws over the sidebar at laptop widths, and does it differently
// in every collapse state. Sticky bounds the bar to the content column by
// construction. Pass "fixed" through className to opt back into the old
// behaviour when the page has no sidebar to collide with.
//
// The one shadow in the set: this bar floats over the page it edits, and the
// elevation is what says so. Everything else in Vibra sits on a hairline.
const BAR =
  "sticky z-50 mx-auto flex max-w-2xl items-center gap-3 elev-2 px-3 py-2 text-popover-foreground data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-2 data-[state=open]:duration-(--duration-slow) data-[state=open]:ease-(--ease-emphasized)"

const POSITION = {
  bottom: "bottom-4 data-[state=open]:slide-in-from-bottom-4",
  top: "top-4 data-[state=open]:slide-in-from-top-4",
} as const

/** Which edge a floating bar docks to. */
export type ActionBarPosition = keyof typeof POSITION

export type ActionBarProps = React.ComponentProps<"div"> & {
  /** Draws nothing while false; only the bar's empty status line stays in the page, so its first message is heard. */
  open: boolean
  message?: React.ReactNode
  onSave: () => void | Promise<void>
  onDiscard: () => void
  /** Locks both buttons and spins the save button; drive it from your own mutation state. */
  saving?: boolean
  saveText?: string
  discardText?: string
  position?: ActionBarPosition
}

/** The save-or-discard bar that appears over a page with unsaved edits. */
function ActionBar({
  className,
  open,
  message = "You have unsaved changes.",
  onSave,
  onDiscard,
  saving = false,
  saveText = "Save changes",
  discardText = "Discard",
  position = "bottom",
  ...props
}: ActionBarProps) {
  return (
    <>
      {/* The status line is in the page before the bar opens: a live region
          that arrives with its words already in it is often not read out at
          all. It says the message while the bar is open — so a bar that
          appears mid-edit is heard without moving focus off the field being
          edited — and nothing while it is closed. The bar's own copy of the
          words is plain text, so they are said once. */}
      <span role="status" data-slot="action-bar-status" className="sr-only">
        {open ? message : null}
      </span>
      {open ? (
        <div
          role="region"
          aria-label="Unsaved changes"
          data-slot="action-bar"
          data-state="open"
          data-position={position}
          className={cn(BAR, POSITION[position], className)}
          {...props}
        >
          <span data-slot="action-bar-message" className="min-w-0 flex-1 truncate text-sm">
            {message}
          </span>
          <div className="flex shrink-0 items-center gap-2">
            <Button type="button" variant="ghost" size="sm" disabled={saving} onClick={onDiscard}>
              {discardText}
            </Button>
            <Button
              type="button"
              size="sm"
              disabled={saving}
              aria-busy={saving || undefined}
              onClick={() => {
                void onSave()
              }}
            >
              {/* Decoration only: the button keeps its own name, and aria-busy carries the state. */}
              {saving ? <Loader2Icon aria-hidden="true" className="animate-spin motion-reduce:animate-none in-data-[motion=reduced]:animate-none" /> : null}
              {saveText}
            </Button>
          </div>
        </div>
      ) : null}
    </>
  )
}

export type SelectionActionBarProps = React.ComponentProps<"div"> & {
  /** How many rows are selected; the bar disappears at zero, and its empty status line stays. */
  count: number
  onClear: () => void
  /** The bulk actions, usually two or three buttons. */
  children: React.ReactNode
  /** Singular noun for what is selected; the plural gets an "s". */
  itemLabel?: string
  position?: ActionBarPosition
}

/** The bulk-action bar that appears once rows in a table are selected. */
function SelectionActionBar({
  className,
  count,
  onClear,
  children,
  itemLabel = "item",
  position = "bottom",
  ...props
}: SelectionActionBarProps) {
  const summary = `${count} ${itemLabel}${count === 1 ? "" : "s"} selected`

  return (
    <>
      {/* In the page before anything is selected, so the first pick is heard
          as surely as the fourth, without moving focus away from the table;
          it says nothing while nothing is selected. The bar's own count is
          plain text, so it is said once. */}
      <span role="status" data-slot="selection-action-bar-status" className="sr-only">
        {count > 0 ? summary : null}
      </span>
      {count > 0 ? (
        <div
          role="region"
          aria-label="Selected items"
          data-slot="selection-action-bar"
          data-state="open"
          data-position={position}
          className={cn(BAR, POSITION[position], className)}
          {...props}
        >
          <span data-slot="selection-action-bar-message" className="min-w-0 flex-1 truncate text-sm tabular-nums">
            {summary}
          </span>
          <Button type="button" variant="ghost" size="icon-sm" aria-label="Clear selection" onClick={onClear}>
            <XIcon aria-hidden="true" />
          </Button>
          <div data-slot="selection-action-bar-actions" className="flex shrink-0 items-center gap-2">
            {children}
          </div>
        </div>
      ) : null}
    </>
  )
}

export { ActionBar, SelectionActionBar }