Skip to content
Product block

Bulk Action Bar

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.

405 lineslucide-reactAdded 27 Aug 2026Updated 10 Sept 2026
  • table
  • bulk actions
  • selection
  • undo
  • destructive

What's included

  • components/data-table-bulk-actions.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add data-table-bulk-actions

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Members

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.

Members matching the current filter. Selecting every row on this page offers to extend the selection to all matching rows.
Select the 8 rows on this pageMemberPlanLast seen

Ama Boateng

ama@meridianfoods.com

Team2 minutes ago

Deniz Kaya

deniz@halden.co

Pro1 hour ago

Rafael Ortiz

rafael@northwind.dev

Free3 days ago

Yuki Tanaka

yuki@brightloom.jp

Team6 days ago

Nadia Haddad

nadia@cedarworks.io

Pro2 weeks ago

Tom Sørensen

tom@fjordlabs.no

Free1 month ago

Priya Raman

priya@sundara.in

Team1 month ago

Elise Marchand

elise@atelier9.fr

Free3 months ago

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/data-table-bulk-actions.tsx
'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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

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'

Customize

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.

Props

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.

PropTypeDefault
rowsBulkRow[]DEFAULT_ROWS
totalMatchingRows the current filter matches beyond this page.number1284
confirmThresholdAt or above this many rows, deleting asks for the count to be typed.number50
classNamestring''

Not using React?

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.

data-table-bulk-actions.html
<!--
  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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Data Tables

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 Tables

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Data Tables blocks

View category
Open the full page for this block

Sortable Selectable Table

Type-aware sorting with aria-sort, a header checkbox that goes properly indeterminate, and selection that survives a re-sort.

Data Tables297 lines1 dep
Open the full page for this block

Table Search & Filter Bar

Search, filter count and view switch, with active filters shown as removable chips so a filtered table never looks unfiltered.

Data Tables180 lines1 dep
Open the full page for this block

Expandable Detail Rows

Rows that open into a full-width detail panel via colSpan, with aria-expanded and aria-controls wired to the detail row.

Data Tables205 lines1 dep
Open the full page for this block

Column Show, Hide & Reorder

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.

Data Tables219 lines1 dep
Open the full page for this block

Inline Editable 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.

Data Tables301 lines1 dep
Open the full page for this block

Grouped Table with Subtotals

Collapsible group headers that carry their own subtotals while collapsed, with a grand total that sums every row rather than only the visible ones.

Data Tables238 lines1 dep