Data Table
The single most-demanded surface in any app — orders, tickets, contacts, users,
records — is one thing: a sortable, searchable, bulk-selectable table. The
data-table wrapper handles the 80% case entirely client-side: hand it a rows
array and a columns definition and it sorts, searches, selects, toggles column
visibility, and switches density with no backend round-trip. For 10k+ rows you
drive the same UI from Livewire via the server contract.
Basic Usage
Define columns (each {key, label}) and pass rows. Headers are sortable by
default — click to sort, click again to reverse.
Status Badges
A cellType: 'badge' column renders a tinted intent pill. These words map automatically,
matched case-insensitively — this is the whole list, not an excerpt:
| Intent | Words |
|---|---|
success |
active, approved, completed, done, met, paid, pass, success |
warning |
at-risk, pending, processing, review, warning |
danger |
canceled, error, failed, inactive, overdue, rejected |
Anything else is neutral.
Your own words
The list above is English status vocabulary, and most applications have words that are not on it — a domain term, another language, a status only your product has. Name them on the column:
// 1. `intents` maps a cell VALUE to one of the seven badge intents. Case does not matter.
// 2. Words you do not name keep the automatic mapping above.
[
'key' => 'status',
'label' => 'Status',
'cellType' => 'badge',
'intents' => ['vip' => 'success', 'lapsed' => 'danger', 'storniert' => 'danger'],
]
The intent must be one of badge's seven — primary, accent, success, warning,
danger, info, neutral. Anything else falls back to neutral rather than rendering an
unstyled pill. This line named four for a while, and the tint map in the component has
carried all seven the whole time: 'intents' => ['processing' => 'accent'] worked and the
page said it could not.
Selection & Bulk Actions
Add selectable for per-row checkboxes plus a tri-state select-all. When rows
are selected, a bulk-action bar appears — fill it with the bulkActions slot.
Give the bar its own bulk-actions-label to expose it as a named landmark;
leave it off and it stays a plain container.
Search, Density & Column Manager
searchable adds a client-side filter box, the density toggle switches row
padding, and column-manager lets the user show or hide columns.
Row Actions
The rowActions slot adds a trailing actions column. Compose a dropdown or
buttons; the slot renders once per row.
Name the control after the row it acts on. The slot is rendered inside the row loop, so the
current row is in scope as row — write the binding with a doubled colon (::aria-label) and
Blade passes it through untouched. Without it every row offers a control called the same thing,
which is one accessible name repeated as many times as there are rows: a screen-reader user
hearing "View, View, View" down the rotor cannot tell which invoice they are on.
Server Mode (Large Datasets)
For datasets too large to ship to the browser, add the server flag and drive
sort / search / selection from Livewire. The component stops sorting and
filtering locally and instead emits sort-change, search-change, and
selection-change events for you to handle:
search-change waits for typing to settle before it fires — 300 ms by default,
tunable with searchDebounce. That matters most in exactly this mode: without
the wait, the wiring below sends one Livewire round trip per CHARACTER, so an
eight-letter query asks your application eight questions and discards the first
seven answers, each of them a database query. Set searchDebounce to 0 if you
throttle on your own side.
{{-- 1. Server mode: the component renders the current page; you re-query --}}
<x-wirekit::data-table
server
selectable
name="selectedIds"
:columns="$columns"
:rows="$this->rows()->items()"
x-on:sort-change="$wire.set('sort', $event.detail)"
x-on:search-change="$wire.set('search', $event.detail.value)"
{{-- 2. While the round trip is out: aria-busy on the table, and one status
announcement. Without it the previous page's rows stay on screen with
nothing saying a query is running. --}}
:loading="true"
wire:loading.attr="data-wk-loading"
/>
{{-- 3. Compose pagination below it --}}
{{ $this->rows()->links() }}
The loading state
server exists for datasets whose round trip is slow, and every sort click and every keystroke
in the search field starts one. Bind loading so the table can say so:
<x-wirekit::data-table server :loading="$wireLoading" :columns="$columns" :rows="$rows" />
The simplest source is Livewire's own wire:loading on a wrapper around the component — the
standard tool, and the one the skeleton page teaches:
<div wire:loading.class="opacity-60">
<x-wirekit::data-table server :columns="$columns" :rows="$this->rows()->items()" />
</div>
What loading adds on top of that is the part a wrapper cannot reach: aria-busy="true" on
the table itself, and a single polite announcement through the status region the component
already owns. Without it a sighted reader cannot tell a slow query from a click that did
nothing, and a screen-reader user hears nothing at all until the rows change.
// 3. A reusable trait carries the server-side state contract
trait WithDataTable
{
public array $sort = ['key' => null, 'dir' => 'asc'];
public string $search = '';
public array $selectedIds = [];
public function rows(): \Illuminate\Contracts\Pagination\LengthAwarePaginator
{
return Order::query()
->when($this->search, fn ($q) => $q->where('ref', 'like', "%{$this->search}%"))
->when($this->sort['key'], fn ($q) => $q->orderBy($this->sort['key'], $this->sort['dir']))
->paginate(25);
}
}
The selection-change event (and the hidden input the name prop creates)
carry the selected id list, so wire:model="selectedIds" stays in sync for bulk
operations.
Column Definition
| Key | Required | Description |
|---|---|---|
key |
yes | The row field to read |
label |
yes | The header text |
sortable |
no | false disables sorting for the column |
align |
no | left (default) · center · right |
cellType |
no | text (default) · number (tabular figures) · code (monospace) · badge (one pill) · badges (one pill per list entry) |
intents |
no | For a badge column: map a cell value to any intent <x-wirekit::badge> accepts — primary · accent · info · success · warning · danger · neutral — so a status word the built-in list never had lands on the right color instead of neutral. An intent outside that set falls back to neutral rather than failing |
subKey |
no | A second row field, drawn as a quieter line under the value. See below. |
intentKey |
no | A row field naming this cell's intent. A row that names one wears a pill; a row that names none renders in the column's base type. See below. |
avatarKey |
no | A row field holding initials, drawn as a colored circle before the value. See below. |
prominence |
no | strong or muted — how loud the column reads. Absent is the middle |
Two lines in one cell
An admin table's ordinary cell is two lines: the order number over its date, the customer over
their email, the product over its SKU. Give the column a subKey and it reads the second field
for the quieter line.
A row whose second field is empty or missing draws one line, not one line and a gap — whether a
row has the value is data, and the third row above shows it. On a number column both lines are
tabular, so the figures stay aligned under each other.
subKey covers the common shape rather than being a general escape hatch: a column still cannot
render arbitrary markup, because the table body is built in the browser from the rows you pass
and there is no per-row Blade cell to hand back.
A pill only when the value earns it
A stock column is the example everyone has seen: 0 reads Out in red, a handful reads
3 low in amber, and a healthy number is just a number. Three things change with the value —
the color, the words, and whether there is a pill at all — so a rule attached to the column
cannot express it. Point intentKey at a row field instead and each row says what it is.
// 1. The threshold stays in your application, beside the query that produced the number —
// ordinary PHP you can test, rather than a comparison syntax inside a config array.
$rows = $products->map(fn ($p) => [
'id' => $p->id,
'product' => $p->name,
'stock' => match (true) {
$p->stock === 0 => 'Out',
$p->stock < 10 => $p->stock.' low',
default => (string) $p->stock,
},
// 2. `null` is the "no pill" answer, not a missing one.
'stockIntent' => match (true) {
$p->stock === 0 => 'danger',
$p->stock < 10 => 'warning',
default => null,
},
]);
The pill is the same one a badge column wears, and it accepts the same seven intents. An
unrecognized word draws no pill rather than an unstyled one, so a typo shows as a plain value
instead of an invisible cell. A badge column ignores intentKey: there the intent already
comes from the value, and two sources for one color is one too many.
A face in the cell
A customer row usually opens with a colored initials circle. avatarKey names the row field
holding the initials, and the color comes from the same palette
<x-wirekit::avatar from-initials> uses — so one person is one color
across the whole application, not one color here and another on their profile.
The circle is hidden from screen readers: it restates the name standing next to it, and read
aloud the row would begin "M P Maya Patel". A row without initials draws no circle rather than an
empty one. Combine it with subKey, as above, and the cell is a face over two lines.
Codes, and how loud a column reads
Two column settings answer questions a table asks and prose does not.
cellType: 'code' sets the cell in a monospace face. A column of SKUs, barcodes, order
references or hashes is read down, not across — and in a proportional face an I and an M
put the fourth character of two rows in different places, so the eye loses the column it was
following. It is the same answer number gives figures, for the same reason.
prominence says how loud a column reads, on one axis with three positions. strong is the
column carrying the row's identity — its reference, its name, its total. muted is the one that
is context rather than content: an address beside a name, a timestamp beside an amount. Absent is
the middle, and most columns belong there — a table where everything shouts says nothing, and one
where nothing does gives the eye no way in.
The second line of a code column stays monospace too, so a barcode under its SKU keeps the
same column edge as the value above it.
A column of tags
cellType: 'badges' reads a list and draws one pill per entry. How many a row carries is
data — a customer is a subscriber, or a lapsed VIP, or neither — and badge can only ever draw
one. Each entry maps through the same intents table a single badge uses.
An empty list draws nothing rather than an empty pill, and a row missing the field behaves the same way. A single string is accepted too and becomes one pill — a column that was configured with a scalar by mistake still shows its value instead of going quietly blank.
There is deliberately no cap with a "+2 more" affordance. That would be a second control with its own keyboard question, built against a guess about how many entries a real row carries; the pills wrap instead.
The Empty Screen
emptyText states that nothing is here. It cannot state what to do about it — and the empty
table is the screen a new user reaches first, before there is any data to look at. Pass an
empty slot to put a real starting point there instead:
The slot replaces the muted line rather than joining it, so the table never shows a call to
action and a sentence saying nothing is here at the same time. Leave the slot off and
emptyText behaves exactly as before.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
loading |
bool | false |
A server round trip is in flight. Sets aria-busy on the table and announces once through the status region. Only meaningful with server; drive it from wire:loading. See the loading state. |
rows |
array | [] |
Row objects |
columns |
array | [] |
Column definitions (see above) |
rowKey |
string | 'id' |
Unique id field (selection + morph keying) |
selectable |
bool | false |
Per-row + select-all checkboxes |
searchable |
bool | false |
Toolbar search box |
density |
string | 'comfortable' |
comfortable · compact |
columnManager |
bool | false |
Show/hide-columns dropdown |
hidden |
array | [] |
Initially-hidden column keys |
server |
bool | false |
Server-driven — stop local sort/filter, emit events only |
searchPlaceholder |
string | 'Search…' |
Search box placeholder |
searchDebounce |
int | 300 |
Milliseconds to wait after the last keystroke before emitting search-change. Only the event waits — the client-side filter still updates on every keystroke. 0 emits immediately |
emptyText |
string | 'No results' |
Empty-state message. Replaced entirely by the empty slot when one is supplied |
caption |
string|null | null |
Accessible table caption / name |
bulkActionsLabel |
string|null | null |
Accessible name for the bulk-action bar — and the switch that makes it a landmark |
name |
string|null | null |
Hidden-input name mirroring selected ids |
scope |
string|null | null |
Scoped personalization name |
Accessibility
- The horizontally-scrolling wrapper carries
tabindex="0"and a focus ring in every configuration, so the grid is keyboard-reachable (WCAG 2.1.1). - The wrapper becomes a screen-reader landmark only when you pass a
caption. It is then arole="region"pointed at that caption witharia-labelledby. Without one it stays a plain focusable scroller: the old fallback name, "Data table", said only what everything on the page already was, so three tables on a dashboard were three landmarks with one name — which axe reports aslandmark-unique. The caption is also the table's ownsr-only<caption>, so naming it does two jobs at once. - The bulk-action bar becomes a landmark only when you pass a
bulkActionsLabel, on the same rule and for the same reason — a built-in name would make every table's bar the same rotor entry. Without one the bar is a plain container: it needs notabindex(it does not scroll), its buttons are in the tab order on their own, and the selection count is announced through itsaria-livespan rather than through the landmark. Name it after the surface ("Subscriber bulk actions"). - Sortable headers are buttons carrying
aria-sort(ascending/descending/none); the sort direction is shown with a caret glyph, not color. - The select-all checkbox is tri-state (
indeterminatewhen a subset is selected); the selection count is announced via anaria-liveregion. - A
captionbecomes the table's accessible name.
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move through the toolbar, headers, checkboxes, and row actions |
Enter / Space |
Sort a column, toggle a checkbox, or trigger an action |
Design Tokens
| Element | Token |
|---|---|
| Header text | --color-wk-text-muted |
| Row hover | --color-wk-bg-subtle |
| Selected row | --color-wk-bg-muted |
| Bulk-action bar | --color-wk-bg-muted |
| Checkbox accent | --color-wk-accent |
| Badge (success/warning/danger) | --color-wk-success / --color-wk-warning / --color-wk-danger |
| Focus ring | --color-wk-ring |
Customization
Override the defaults via config/wirekit.php:
'components' => [
'data-table' => [
'density' => 'compact',
'selectable' => true,
'searchable' => true,
],
],