Table Toolbar
The filter row above a table or row list: scope controls on the left, whole-table actions on the right.
Installation
$
Usage
import { TableToolbar, TableToolbarViews, TableToolbarControls, TableToolbarActions } from "@/components/ui/table-toolbar"Examples
Default
Scope controls left, actions right. Give a search field a width (w-64): Input is w-full, so a cap alone leaves its size to whatever surrounds it.
| Order | Customer | Amount |
|---|---|---|
| #3391 | Priya Nair | $1,240 |
| #3390 | Sam Okafor | $860 |
With a view switcher
A view switcher sits first, before the filters that narrow the list. Keep the Tabs root around both the toolbar and the panels. Size everything lg: a TabsList is 40px tall, and lg is the control rung that matches it.
| Order | Customer | Amount |
|---|---|---|
| #3391 | Priya Nair | $1,240 |
| #3390 | Sam Okafor | $860 |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
// @use-when the row of controls above a table: search, filters, a view switch,
// a primary action.
function TableToolbar({ className, style, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="table-toolbar"
// NOT role="toolbar". That role promises roving-tabindex arrow-key
// navigation between the controls, and a screen reader user who hears
// "toolbar" will try it. This is a plain row of independently tabbable
// controls, so claiming the role would break the expectation it sets.
className={cn("flex flex-wrap items-center", className)}
// items-center, and one baseline: every control here is the same 32px
// height, so there is nothing to align to an edge. `items-end` only ever
// looks right when the controls carry labels of differing heights, which
// is the arrangement this component exists to prevent.
style={{ gap: "var(--spacing-rhythm-cluster)", ...style }}
{...props}
/>
)
}
/** The view switcher: which set of rows the table is showing. Sits first,
* before the filters, because it changes WHAT is listed rather than narrowing
* it. Put a `TabsList` in here and keep the `Tabs` root around the toolbar and
* the panels together, so the switcher is part of the row instead of a second
* bar stacked above it. */
function TableToolbarViews({ className, style, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="table-toolbar-views"
className={cn("flex items-center", className)}
style={{ gap: "var(--spacing-rhythm-cluster)", ...style }}
{...props}
/>
)
}
/** The scope controls: what the table below is showing. */
function TableToolbarControls({ className, style, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="table-toolbar-controls"
// NOT flex-wrap, and min-w-0. Input ships `w-full`, so inside a WRAPPING
// row its hypothetical main size is the whole container and it can never
// share a line: the cluster stacked into one control per row. Without
// wrapping, a percentage width resolves to auto during intrinsic sizing,
// so the field contributes its real content width and the cluster stays
// one row. Give a search field a definite width anyway (`w-64`) so its
// size is a decision rather than a side effect of its placeholder.
className={cn("flex min-w-0 items-center", className)}
style={{ gap: "var(--spacing-rhythm-cluster)", ...style }}
{...props}
/>
)
}
/** Actions on the table as a whole. `ms-auto`, so they meet the table's right
* edge: the toolbar spans the same width as the table under it, which is what
* makes that edge a real alignment rather than an arbitrary gap. Logical
* property, not `ml-auto`, so it flips under RTL. */
function TableToolbarActions({ className, style, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="table-toolbar-actions"
className={cn("ms-auto flex flex-wrap items-center", className)}
style={{ gap: "var(--spacing-rhythm-cluster)", ...style }}
{...props}
/>
)
}
// The filter row that sits directly on top of a table or row list: scope
// controls on the left, whole-table actions on the right.
//
// THE CONTROLS CARRY NO VISIBLE LABELS. A toolbar control states itself: the
// combobox trigger reads "All projects", the date button reads the date, the
// search field has a placeholder. Stacking a <Label> over each one doubles the
// toolbar's height and puts a wall of text between the table and whatever sits
// above it, which is exactly how this row went wrong before it was a component.
// Give each control an `aria-label` instead, so it keeps an accessible name
// without a visible one. A control that genuinely cannot explain itself in its
// own resting state belongs in a form, not a toolbar.
//
// ONE HEIGHT ACROSS THE ROW, and the height is 40. The control ladder runs
// 24 / 28 / 32 / 40 (`xs`/`sm`/`default`/`lg`), but a `TabsList` is its trigger
// PLUS its own `p-1`, so it renders 36 / 40 / 48. The two ladders meet at
// exactly one value: Tabs `default` (40) beside controls at `size="lg"` (40).
// A toolbar carrying a view switcher therefore takes `lg` controls, and one at
// `default` leaves the tabs 8px taller than everything beside them, which is
// the mismatch that reads as sloppy even when the alignment is technically
// centred. A toolbar with NO switcher is free to sit at `default` throughout.
//
// It ships NO bottom margin on purpose. A toolbar introduces the table under
// it, which is the `bind` rung, but a child never sets a margin to make room
// for a sibling: wrap the pair in `<Stack gap="bind">` and let the container
// own that space. (Same rule that deleted PageHeader's built-in mb-6, where a
// flex gap and a child margin did not collapse and the seam rendered at the
// sum of the two.)
export {
TableToolbar,
TableToolbarViews,
TableToolbarControls,
TableToolbarActions,
}