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-barNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ActionBar } from "@/components/ui/action-bar"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
const SETTINGS = [
{ id: "weekly", label: "Weekly summary", description: "Every Monday at 9am." },
{ id: "anomalies", label: "Anomaly alerts", description: "When a metric leaves its band." },
{ id: "exports", label: "Export finished", description: "When a scheduled export lands." },
{ id: "invites", label: "Teammate joins", description: "When someone accepts an invite." },
{ id: "digest", label: "Monthly digest", description: "A recap on the first of the month." },
]
const DEFAULTS: Record<string, boolean> = {
weekly: true,
anomalies: false,
exports: true,
invites: false,
digest: false,
}
export default function ActionBarDemo() {
const [enabled, setEnabled] = React.useState(DEFAULTS)
const [dirty, setDirty] = React.useState(false)
const [saving, setSaving] = React.useState(false)
async function save() {
setSaving(true)
await new Promise((resolve) => setTimeout(resolve, 900))
setSaving(false)
setDirty(false)
}
return (
// The bar sticks inside whatever scrolls the page. Here that is this panel;
// in a dashboard it is the shell's content column, which is how the bar
// stays clear of the sidebar at every width.
<div className="h-[280px] w-full overflow-y-auto panel p-4">
<h3 className="text-sm font-medium">Email notifications</h3>
<div className="flex flex-col gap-4 pt-4">
{SETTINGS.map((setting) => (
<div key={setting.id} className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label htmlFor={setting.id}>{setting.label}</Label>
<span className="text-xs text-muted-foreground">{setting.description}</span>
</div>
<Switch
id={setting.id}
checked={enabled[setting.id]}
onCheckedChange={(checked) => {
setEnabled((current) => ({ ...current, [setting.id]: checked }))
setDirty(true)
}}
/>
</div>
))}
</div>
<ActionBar
open={dirty}
saving={saving}
message="Unsaved notification changes"
onSave={save}
onDiscard={() => {
setEnabled(DEFAULTS)
setDirty(false)
}}
/>
</div>
)
}Selection
The bulk-action bar, counting the rows picked and offering what to do with them.
"use client"
import * as React from "react"
import { ArchiveIcon, TagIcon } from "lucide-react"
import { SelectionActionBar } from "@/components/ui/action-bar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
const REPORTS = [
{ id: "r-1041", name: "Weekly revenue" },
{ id: "r-1042", name: "Signup funnel" },
{ id: "r-1043", name: "Churn by plan" },
{ id: "r-1044", name: "Activation cohort" },
{ id: "r-1045", name: "Support backlog" },
{ id: "r-1046", name: "Payment failures" },
]
export default function ActionBarSelection() {
const [selected, setSelected] = React.useState<string[]>(["r-1041", "r-1042"])
return (
<div className="h-[280px] w-full overflow-y-auto panel p-4">
<h3 className="text-sm font-medium">Saved reports</h3>
<div className="flex flex-col gap-3 pt-4">
{REPORTS.map((report) => (
<div key={report.id} className="flex items-center gap-3">
<Checkbox
id={report.id}
checked={selected.includes(report.id)}
onCheckedChange={(checked) =>
setSelected((current) =>
checked ? [...current, report.id] : current.filter((id) => id !== report.id)
)
}
/>
<Label htmlFor={report.id} className="font-normal">
{report.name}
</Label>
<span className="ml-auto font-mono text-xs text-muted-foreground">{report.id}</span>
</div>
))}
</div>
<SelectionActionBar
count={selected.length}
itemLabel="report"
onClear={() => setSelected([])}
>
<Button variant="ghost" size="sm">
<TagIcon data-icon="inline-start" />
Tag
</Button>
<Button size="sm">
<ArchiveIcon data-icon="inline-start" />
Archive
</Button>
</SelectionActionBar>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Whether there is anything to save; false draws nothing but the empty status line the first message is spoken from. |
| message | React.ReactNode | You 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. |
| saving | boolean | false | Locks both buttons and spins the save button. |
| saveText | string | Save changes | The save button's label; keep the verb the same one the toast uses. |
| discardText | string | Discard | The discard button's label. |
| position | "bottom" | "top" | "bottom" | Which edge the bar docks to, and the direction it slides in from. |
| SelectionActionBar.count | number | — | 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.children | React.ReactNode | — | The bulk actions, usually two or three buttons. |
| SelectionActionBar.itemLabel | string | item | Singular noun for what is selected; the plural gets an s. |
Dependencies
Registry
npm
Source
"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 }