Sortable Selectable Table
Type-aware sorting with aria-sort, a header checkbox that goes properly indeterminate, and selection that survives a re-sort.
The selection bar that asks the question most tables skip: this page, or all 1,284 rows the filter matches. Typed confirmation above a threshold, an undo with the window counting down, and a select-all that names its own scope.
components/data-table-bulk-actions.tsxnpx hoverlab add data-table-bulk-actions
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Showing 8 of 1,284 matching “active in the last year”
8 rows selected on this page
All 8 rows on this page are selected.
| Select the 8 rows on this page | Member | Plan | Last seen |
|---|---|---|---|
Ama Boateng ama@meridianfoods.com | Team | 2 minutes ago | |
Deniz Kaya deniz@halden.co | Pro | 1 hour ago | |
Rafael Ortiz rafael@northwind.dev | Free | 3 days ago | |
Yuki Tanaka yuki@brightloom.jp | Team | 6 days ago | |
Nadia Haddad nadia@cedarworks.io | Pro | 2 weeks ago | |
Tom Sørensen tom@fjordlabs.no | Free | 1 month ago | |
Priya Raman priya@sundara.in | Team | 1 month ago | |
Elise Marchand elise@atelier9.fr | Free | 3 months ago |
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <DataTableBulkActions> — the selection bar, and the question it must ask.
*
* Data Tables already had the sortable table, the toolbar, pagination, the
* expandable row and the column manager. Every one of them treats
* selection as a checkbox that lights up. None of them answers what
* happens when the person presses the button.
*
* THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
*
* "Select all" is two different commands and almost every table ships only
* one of them. Ticking the header box selects the 8 rows on this page.
* Wanting all 1,284 rows that match the filter is a *different* intent,
* and a table that silently upgrades one into the other is how somebody
* archives a year of records meaning to archive a screenful. So the
* upgrade is offered as a sentence with the real number in it, taken only
* on a click, and shown as a distinct state afterwards — the bar says
* "1,284 rows across every page" and stays saying it.
*
* DESTRUCTION COSTS MORE THE MORE OF IT THERE IS
*
* Deleting three rows is a button. Deleting 1,284 is a different act, and
* a confirm dialog that reads the same for both trains people to dismiss
* it. Above the threshold the button asks for the count to be typed —
* the one interaction that cannot be muscle-memoried — and below it, it
* just runs, because friction on the small case is what teaches people to
* stop reading.
*
* UNDO IS A PROMISE WITH A CLOCK ON IT
*
* The bar after an action says how long the undo lasts, counting down,
* because "Undo" with no horizon is a promise the backend will break
* quietly. When it expires the row says so rather than the control simply
* vanishing.
*
* ACCESSIBILITY: a real `<table>` with a caption; the header box carries
* its indeterminate state through a ref (there is no such attribute in
* markup); the action bar is `aria-live="polite"` and every count is in
* words; the destructive confirmation is a labelled input, not a
* placeholder pretending to be one.
*/
import * as React from 'react'
import { Archive, Check, Loader2, Tag, Trash2, Undo2, X } from 'lucide-react'
export interface BulkRow {
id: string
name: string
email: string
plan: string
lastSeen: string
}
export interface DataTableBulkActionsProps {
rows?: BulkRow[]
/** Rows the current filter matches beyond this page. */
totalMatching?: number
/** At or above this many rows, deleting asks for the count to be typed. */
confirmThreshold?: number
className?: string
}
const DEFAULT_ROWS: BulkRow[] = [
{ id: 'u-1', name: 'Ama Boateng', email: 'ama@meridianfoods.com', plan: 'Team', lastSeen: '2 minutes ago' },
{ id: 'u-2', name: 'Deniz Kaya', email: 'deniz@halden.co', plan: 'Pro', lastSeen: '1 hour ago' },
{ id: 'u-3', name: 'Rafael Ortiz', email: 'rafael@northwind.dev', plan: 'Free', lastSeen: '3 days ago' },
{ id: 'u-4', name: 'Yuki Tanaka', email: 'yuki@brightloom.jp', plan: 'Team', lastSeen: '6 days ago' },
{ id: 'u-5', name: 'Nadia Haddad', email: 'nadia@cedarworks.io', plan: 'Pro', lastSeen: '2 weeks ago' },
{ id: 'u-6', name: 'Tom Sørensen', email: 'tom@fjordlabs.no', plan: 'Free', lastSeen: '1 month ago' },
{ id: 'u-7', name: 'Priya Raman', email: 'priya@sundara.in', plan: 'Team', lastSeen: '1 month ago' },
{ id: 'u-8', name: 'Elise Marchand', email: 'elise@atelier9.fr', plan: 'Free', lastSeen: '3 months ago' },
]
/** How long an undo is honoured. Stated to the user, not just implemented. */
const UNDO_SECONDS = 12
export function DataTableBulkActions({
rows = DEFAULT_ROWS,
totalMatching = 1284,
confirmThreshold = 50,
className = '',
}: DataTableBulkActionsProps) {
/*
Per-instance prefix for every id this block emits.
The literals these replaced were a latent duplicate the moment the
block appeared twice on one document, and `aria-labelledby` on a
duplicated id resolves to the first match -- so the second copy was
labelled by the first copy's heading. Client component, so `useId` is
the right tool.
*/
const uid = React.useId()
/*
* Opens with every row on the page ticked, which is not the state a
* real table starts in.
*
* It is the state this block is *about*: the action bar is up, and the
* offer to extend the selection to all 1,284 matching rows — the whole
* reason the component exists — only appears once the page is full. A
* demo that opened empty would be a screenshot of a table.
*/
const [selected, setSelected] = React.useState<string[]>(() => rows.map((r) => r.id))
/** True once the visitor has upgraded the selection past this page. */
const [wholeFilter, setWholeFilter] = React.useState(false)
const [confirmText, setConfirmText] = React.useState('')
const [pendingDelete, setPendingDelete] = React.useState(false)
const [undo, setUndo] = React.useState<{ label: string; left: number } | null>(null)
const count = wholeFilter ? totalMatching : selected.length
const allOnPage = rows.length > 0 && selected.length === rows.length
const someOnPage = selected.length > 0 && !allOnPage
const headerBox = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
/* Indeterminate is a property; JSX cannot set it. */
if (headerBox.current) headerBox.current.indeterminate = someOnPage
}, [someOnPage])
/* The undo clock. A promise with no visible horizon is not a promise. */
React.useEffect(() => {
if (!undo) return
if (undo.left <= 0) return
const timer = window.setTimeout(
() => setUndo((u) => (u ? { ...u, left: u.left - 1 } : null)),
1000,
)
return () => window.clearTimeout(timer)
}, [undo])
function clear() {
setSelected([])
setWholeFilter(false)
setConfirmText('')
setPendingDelete(false)
}
function run(label: string) {
setUndo({ label, left: UNDO_SECONDS })
clear()
}
const needsTyping = count >= confirmThreshold
const typedCorrectly = confirmText.trim() === String(count)
return (
<section className={`mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 ${className}`}>
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<header className="flex flex-wrap items-center gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-foreground">Members</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Showing {rows.length} of {totalMatching.toLocaleString('en-US')} matching
“active in the last year”
</p>
</div>
</header>
{/*
The action bar. Live, because it appears under the user's hands
and the number inside it is the entire basis for the button next
to it.
*/}
{count > 0 ? (
<div
aria-live="polite"
className="border-b border-border bg-muted/50 px-5 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 flex-1 text-sm text-foreground">
<strong className="font-semibold tabular-nums">
{count.toLocaleString('en-US')}
</strong>{' '}
{count === 1 ? 'row' : 'rows'} selected
{wholeFilter ? ' across every page' : ' on this page'}
</p>
<button
type="button"
onClick={() => run(`Tagged ${count.toLocaleString('en-US')} members`)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Tag aria-hidden className="h-3.5 w-3.5" />
Tag
</button>
<button
type="button"
onClick={() => run(`Archived ${count.toLocaleString('en-US')} members`)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Archive aria-hidden className="h-3.5 w-3.5" />
Archive
</button>
<button
type="button"
onClick={() => {
if (needsTyping) setPendingDelete(true)
else run(`Deleted ${count.toLocaleString('en-US')} members`)
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-destructive px-3 text-xs font-semibold text-destructive-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
Delete
</button>
<button
type="button"
onClick={clear}
className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-3.5 w-3.5" />
Clear
</button>
</div>
{/*
The upgrade, offered rather than assumed. This sentence is
the difference between archiving a screenful and archiving a
year, and it only ever appears when the whole page is ticked.
*/}
{allOnPage && !wholeFilter ? (
<p className="mt-2 text-xs text-muted-foreground">
All {rows.length} rows on this page are selected.{' '}
<button
type="button"
onClick={() => setWholeFilter(true)}
className="rounded font-medium text-primary underline underline-offset-2 transition hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Select all {totalMatching.toLocaleString('en-US')} rows that match this
filter
</button>
</p>
) : null}
{wholeFilter ? (
<p className="mt-2 text-xs text-muted-foreground">
Every row matching the filter is selected, including rows you have not
seen.{' '}
<button
type="button"
onClick={() => setWholeFilter(false)}
className="rounded font-medium text-primary underline underline-offset-2 transition hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Just this page
</button>
</p>
) : null}
{/*
Typed confirmation, above the threshold only. The count is the
string because it is the one thing that changes with the size
of the act — a fixed word like DELETE becomes muscle memory
after the second time.
*/}
{pendingDelete ? (
<div className="mt-3 rounded-xl border border-destructive/40 bg-destructive/5 p-3">
<label
htmlFor={`${uid}-bulk-confirm`}
className="block text-xs font-medium text-foreground"
>
This deletes {count.toLocaleString('en-US')} members and cannot be
undone. Type <strong className="tabular-nums">{count}</strong> to
confirm.
</label>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
id={`${uid}-bulk-confirm`}
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
inputMode="numeric"
autoComplete="off"
className="h-8 w-28 rounded-lg border border-field bg-background px-2 text-sm tabular-nums text-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
disabled={!typedCorrectly}
onClick={() => run(`Deleted ${count.toLocaleString('en-US')} members`)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-destructive px-3 text-xs font-semibold text-destructive-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Delete {count.toLocaleString('en-US')}
</button>
<button
type="button"
onClick={() => {
setPendingDelete(false)
setConfirmText('')
}}
className="rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Cancel
</button>
</div>
</div>
) : null}
</div>
) : null}
{/* What just happened, and for how much longer it can be taken back. */}
{undo ? (
<div
aria-live="polite"
className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/30 px-5 py-2.5"
>
<Check aria-hidden className="h-4 w-4 text-primary" />
<p className="min-w-0 flex-1 text-sm text-foreground">{undo.label}</p>
{undo.left > 0 ? (
<>
<span className="text-xs tabular-nums text-muted-foreground">
Undo for {undo.left}s
</span>
<button
type="button"
onClick={() => setUndo(null)}
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Undo2 aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
Undo
</button>
</>
) : (
/* Expired, said out loud. A control that just disappears
leaves the user unsure whether they missed it. */
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 aria-hidden className="h-3.5 w-3.5" />
Undo window closed
</span>
)}
</div>
) : null}
<div className="relative overflow-x-auto">
<table className="w-full text-start text-sm">
<caption className="sr-only">
Members matching the current filter. Selecting every row on this page
offers to extend the selection to all matching rows.
</caption>
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="w-10 px-5 py-2">
<input
ref={headerBox}
type="checkbox"
checked={allOnPage}
onChange={() => {
setWholeFilter(false)
setSelected(allOnPage ? [] : rows.map((r) => r.id))
}}
/* accent-primary: the tokens are oklch(), so
hsl(var(--primary)) would render browser-blue. */
className="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span className="sr-only">Select the {rows.length} rows on this page</span>
</th>
<th scope="col" className="px-2 py-2 font-medium">Member</th>
<th scope="col" className="px-2 py-2 font-medium">Plan</th>
<th scope="col" className="px-5 py-2 text-end font-medium">Last seen</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => {
const checked = wholeFilter || selected.includes(row.id)
return (
<tr key={row.id} className={checked ? 'bg-primary/5' : undefined}>
<td className="px-5 py-3">
<label htmlFor={`${uid}-row-${row.id}`} className="sr-only">
Select {row.name}
</label>
<input
id={`${uid}-row-${row.id}`}
type="checkbox"
checked={checked}
onChange={() => {
setWholeFilter(false)
setSelected((s) =>
s.includes(row.id)
? s.filter((x) => x !== row.id)
: [...s, row.id],
)
}}
className="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</td>
<td className="px-2 py-3">
<p className="font-medium text-foreground">{row.name}</p>
<p className="text-xs text-muted-foreground">{row.email}</p>
</td>
<td className="px-2 py-3">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">
{row.plan}
</span>
</td>
<td className="px-5 py-3 text-end text-xs text-muted-foreground">
{row.lastSeen}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/data-table-bulk-actions.tsx and import it where you need the section:
import { DataTableBulkActions } from '@/components/data-table-bulk-actions'2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
rows | BulkRow[] | DEFAULT_ROWS |
totalMatchingRows the current filter matches beyond this page. | number | 1284 |
confirmThresholdAt or above this many rows, deleting asks for the count to be typed. | number | 50 |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Bulk Action Bar — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 ">
<div class="overflow-hidden rounded-2xl border border-border bg-card">
<header class="flex flex-wrap items-center gap-3 border-b border-border px-5 py-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-foreground">Members</h2>
<p class="mt-0.5 text-xs text-muted-foreground">Showing 8 of 1,284 matching “active in the last year”</p>
</div>
</header>
<div aria-live="polite" class="border-b border-border bg-muted/50 px-5 py-3">
<div class="flex flex-wrap items-center gap-2">
<p class="min-w-0 flex-1 text-sm text-foreground"><strong class="font-semibold tabular-nums">8</strong> rows selected on this page</p>
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tag h-3.5 w-3.5" aria-hidden="true"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle></svg>Tag</button>
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-archive h-3.5 w-3.5" aria-hidden="true"><rect width="20" height="5" x="2" y="3" rx="1"></rect><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"></path><path d="M10 12h4"></path></svg>Archive</button>
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-destructive px-3 text-xs font-semibold text-destructive-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 lucide-trash-2 h-3.5 w-3.5" aria-hidden="true"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg>Delete</button>
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>Clear</button>
</div>
<p class="mt-2 text-xs text-muted-foreground">All 8 rows on this page are selected. <button type="button" class="rounded font-medium text-primary underline underline-offset-2 transition hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Select all 1,284 rows that match this filter</button></p>
</div>
<div class="relative overflow-x-auto">
<table class="w-full text-start text-sm">
<caption class="sr-only">Members matching the current filter. Selecting every row on this page offers to extend the selection to all matching rows.</caption>
<thead>
<tr class="border-b border-border text-xs text-muted-foreground">
<th scope="col" class="w-10 px-5 py-2">
<input type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
<span class="sr-only">Select the 8 rows on this page</span>
</th>
<th scope="col" class="px-2 py-2 font-medium">Member</th>
<th scope="col" class="px-2 py-2 font-medium">Plan</th>
<th scope="col" class="px-5 py-2 text-end font-medium">Last seen</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-1" class="sr-only">Select Ama Boateng</label>
<input id="_R_0_-row-u-1" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Ama Boateng</p>
<p class="text-xs text-muted-foreground">ama@meridianfoods.com</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Team</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">2 minutes ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-2" class="sr-only">Select Deniz Kaya</label>
<input id="_R_0_-row-u-2" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Deniz Kaya</p>
<p class="text-xs text-muted-foreground">deniz@halden.co</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Pro</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">1 hour ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-3" class="sr-only">Select Rafael Ortiz</label>
<input id="_R_0_-row-u-3" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Rafael Ortiz</p>
<p class="text-xs text-muted-foreground">rafael@northwind.dev</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Free</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">3 days ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-4" class="sr-only">Select Yuki Tanaka</label>
<input id="_R_0_-row-u-4" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Yuki Tanaka</p>
<p class="text-xs text-muted-foreground">yuki@brightloom.jp</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Team</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">6 days ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-5" class="sr-only">Select Nadia Haddad</label>
<input id="_R_0_-row-u-5" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Nadia Haddad</p>
<p class="text-xs text-muted-foreground">nadia@cedarworks.io</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Pro</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">2 weeks ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-6" class="sr-only">Select Tom Sørensen</label>
<input id="_R_0_-row-u-6" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Tom Sørensen</p>
<p class="text-xs text-muted-foreground">tom@fjordlabs.no</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Free</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">1 month ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-7" class="sr-only">Select Priya Raman</label>
<input id="_R_0_-row-u-7" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Priya Raman</p>
<p class="text-xs text-muted-foreground">priya@sundara.in</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Team</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">1 month ago</td>
</tr>
<tr class="bg-primary/5">
<td class="px-5 py-3">
<label for="_R_0_-row-u-8" class="sr-only">Select Elise Marchand</label>
<input id="_R_0_-row-u-8" type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />
</td>
<td class="px-2 py-3">
<p class="font-medium text-foreground">Elise Marchand</p>
<p class="text-xs text-muted-foreground">elise@atelier9.fr</p>
</td>
<td class="px-2 py-3">
<span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground">Free</span>
</td>
<td class="px-5 py-3 text-end text-xs text-muted-foreground">3 months ago</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Data TablesCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Type-aware sorting with aria-sort, a header checkbox that goes properly indeterminate, and selection that survives a re-sort.
Search, filter count and view switch, with active filters shown as removable chips so a filtered table never looks unfiltered.
Page size, range summary and a fixed-width number window that stays the same size from page 1 to page 200.
Rows that open into a full-width detail panel via colSpan, with aria-expanded and aria-controls wired to the detail row.
Reordering that works from the keyboard rather than by drag alone, pinned columns that cannot be hidden or moved, and a guard so the last visible column cannot be unchecked into a blank table.
Edit cells in the grid with staged changes, per-cell validation, the original value kept beside the attempt, and a save that counts what is still pending.
Collapsible group headers that carry their own subtotals while collapsed, with a grand total that sums every row rather than only the visible ones.