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. Text sorts in the application's
locale, with numbers inside text in natural order ("Item 9" before "Item 10"), and
a column of numbers sorts by value.
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, so 'intents' => ['processing' => 'accent'] is as valid as success or
danger.
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.
A click on a row's checkbox with Shift held picks every row from the one clicked
last to it, and clears them when the click clears its own, as on a
checkbox list.
Its Clear selection button hides the bar with the selection, so it hands the focus to
the select-all checkbox rather than letting it fall to the page.
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 that ignores case and accents
(munchen finds "München"), the density toggle switches row padding, and
column-manager lets the user show or hide columns. The chosen density is tinted and carries an
inner edge in the muted text color, which marks it at 3:1 or more in every preset.
Keeping the reader's columns
A choice made in the column manager lives in the browser until something keeps it. Showing or
hiding a column dispatches wirekit:data-table-columns-changed, with the hidden keys and the
visible ones, and hidden hands a kept choice back:
<x-wirekit::data-table
column-manager
:columns="$columns"
:rows="$rows"
{{-- 1. The choice kept with the account, checked against today's columns --}}
:hidden="$hiddenColumns"
{{-- 2. Every change the reader makes, sent to your component to store --}}
x-on:wirekit:data-table-columns-changed="$wire.saveColumns($event.detail.hidden)"
/>
hidden is followed after the first render too. When a render brings a different list, the
table shows it, so a reset on the server or a stored choice falling back to the default reaches
the screen without a reload. A choice the reader made while that render was on its way is kept:
it is already on its way to your component. The search text, the density and the selection stay
as they are, in client mode and in server mode alike.
Check a stored choice before handing it back. A key that names no column hides nothing, but a choice made against last month's columns can hide one the reader has never seen.
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.
The slot has the whole row, and so does the page. The table writes its rows into the page for
its script. Without a rowActions slot each row carries the fields the table reads: its
row-key, the sort-key it starts on, and the keys its columns name. With the slot each row goes
into the page whole, because the slot may read any of its keys. Hand such a table the fields the
page may show:
{{-- 1. Each user as the three fields the columns and the slot read, not as the whole record. --}}
<x-wirekit::data-table :columns="$columns" :rows="$users->map->only(['id', 'name', 'email'])">
<x-slot:rowActions>
<x-wirekit::button surface="ghost" size="sm" ::aria-label="'Archive ' + row.name" x-on:click="$wire.archive(row.id)">Archive</x-wirekit::button>
</x-slot:rowActions>
</x-wirekit::data-table>
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()"
{{-- 2. The order and the filter the server applied, so the header and the search field describe the rows --}}
:sort-key="$sort['key']"
:sort-dir="$sort['dir']"
:search="$search"
{{-- 3. Each handler starts its round trip at once; the table is busy until it comes back --}}
x-on:sort-change="$wire.set('sort', $event.detail)"
x-on:search-change="$wire.set('search', $event.detail.value)"
/>
{{-- 4. Compose pagination below it --}}
{{ $this->rows()->links() }}
What only the browser holds survives each of these round trips: the text still being typed in the search field, the density, the hidden columns and the selection. The rows and the order and filter you pass come from your component on every render.
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. The table keeps track of those round trips itself, so there is
nothing to wire: from the moment the request carrying a sort or a search leaves until it comes
back, with a response, an error or a cancellation, the scrollable table carries
aria-busy="true" and the status region the component already owns says "Loading results"
once. Without that, a screen-reader user hears nothing at all until the rows change.
The wait belongs to the next request of the Livewire component the table sits in, provided it
starts within a second of the sort or the search. $wire.set(…), as in the recipe above, sends
it at once. A handler that holds the request back, $wire.
for instance, leaves the table quiet, because the wait has not begun.
The visual side is Livewire's own wire:loading, on a wrapper around the component:
<div wire:loading.class="opacity-60">
<x-wirekit::data-table server :columns="$columns" :rows="$this->rows()->items()" />
</div>
loading is for a wait the table cannot see, because it does not start it: an import still
running in the queue, say, whose rows are about to replace the ones on screen. Pass true while
it runs. The render that passes false ends the wait, so something has to deliver that render,
such as wire:poll or an event your component listens for:
<x-wirekit::data-table server :loading="$importRunning" :columns="$columns" :rows="$this->rows()->items()" />
// 5. 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 a hidden input carry the selected id list, so
wire:model="selected stays in sync for bulk operations. The input is
rendered for a wire:model or a name, and a name also submits the selection
with a surrounding form.
The header checkbox in server mode reads and changes the page: it selects the page's rows, or clears them when all are selected, and the rows the reader selected on other pages stay selected. It shows the mixed state while some rows of the page are selected.
Selecting every match
A page of a server table is one page of its matches. Give the table total, the number of matches
on all pages, and once the reader has selected the whole page the bar offers every match, "Select
all 1,284 matches on all pages". Taken, the count in the bar says "1,284 selected, on all pages",
the offer turns into the way back to the page, and selection-change carries allMatches: true.
Only your component knows the matches, so a bulk action reaches them through its query:
{{-- 1. `total` turns the offer on; `allMatches` arrives with every selection-change. --}}
<x-wirekit::data-table
server
selectable
wire:model="selectedIds"
:total="$this->rows()->total()"
:search="$search"
:columns="$columns"
:rows="$this->rows()->items()"
x-on:search-change="$wire.set('search', $event.detail.value)"
x-on:selection-change="$wire.set('allMatches', $event.detail.allMatches)"
/>
While every match is selected, the rows of a page the reader turns to are selected as they arrive.
A render with another search, or another filter for a filter of your own such as a status, clears
every match and says why: the matches of the new filter are not what the reader selected. Clearing
one row ends it too. A screen reader hears what the line in the bar shows: the page selected with
the offer of every match, every match selected, and the page alone again. A list of your own gets
the same through Bulk Selection.
After a bulk action
The selection lives in the browser, so a render your component sends for another reason leaves it
alone. A bulk action that clears its list has to say so: hand the table the list your component
holds, with selected, and every match with all-matches. The first render opens with them, and
after that the table takes them from a response that changed them, while a click the reader made
after the request left stays theirs:
{{-- 1. The list your component holds, beside the binding that receives it. --}}
<x-wirekit::data-table
server
selectable
wire:model="selectedIds"
:selected="$selectedIds"
:all-matches="$allMatches"
:total="$this->rows()->total()"
:columns="$columns"
:rows="$this->rows()->items()"
x-on:selection-change="$wire.set('allMatches', $event.detail.allMatches)"
>
<x-slot:bulkActions>
<x-wirekit::button size="sm" wire:click="archive">Archive</x-wirekit::button>
</x-slot:bulkActions>
</x-wirekit::data-table>
// 2. The action empties the list, and the table follows.
public function archive(): void
{
Order::query()->whereKey($this->selectedIds)->update(['status' => 'archived']);
$this->reset('selectedIds', 'allMatches');
}
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 |
sortStart |
no | 'desc' makes the column's first click sort descending, the direction a column of counts is clicked for. Absent is ascending |
align |
no | left (default) · center · right |
cellType |
no | text (default) · number (tabular figures) · code (monospace) · badge (one pill) · badges (one pill per list entry) · link (a link to the row's own page, see below). A type the table does not know is reported, and draws as text where nothing throws |
hrefKey |
no | For a link column: the row field holding the URL. A row without one shows its value as text |
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:: 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.
A cell that links to its page
The first column of an overview usually names something, and a click on the name opens it. Give
that column cellType: 'link' and name the row field that carries the URL in hrefKey. Each cell
is a real link in the kit's link style, so middle-click, the context menu and "copy link" all work:
nothing navigates in JavaScript.
A row without a URL shows its value as text rather than as a link that goes nowhere. A URL that
would run script, such as javascript: or data:, is refused the same way, because rows are data
and data is not always yours: only http, https, mailto, tel and relative addresses become
links.
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 renders the muted line.
Wide Tables
A data table wider than the space it sits in scrolls sideways, and it says so before anyone
touches it. A soft shadow appears at whichever inline edge has more table behind it and goes away
once that end is reached. On a phone there is no scrollbar until a drag is already underway, so
without the shadow a column cut off at the edge looks like the last one. There is nothing to
switch on, and the shadow works exactly as it does on table.
The table scrolls only sideways, so a vertical swipe or mouse wheel that starts on a row scrolls the page.
On paper a wide data table fits the sheet as far as its words allow, as a printed table does: its column headers wrap between words, and a table with a frozen column takes the sheet's width. Words stay whole, so a table whose words alone are wider than the sheet prints the columns that fit, and a landscape sheet holds more of them.
The shadow is a visual cue only. The scroll container carries tabindex="0" and a focus ring, so
it can be reached and panned by keyboard whether or not a shadow is showing.
Keep the Name in View
In an overview the first column names the row, and on a phone it is the first thing a swipe takes
away — leaving a screen of numbers with nothing to attach them to. sticky-column freezes it.
Four things follow from it, and each is a decision rather than a side effect:
- The frozen column is capped, and wraps. A column that stays in view by taking the view has
replaced the problem it solves: at 390 px an ordinary company name already claimed 42% of the
screen, and every further character comes out of the columns you froze it in order to read. The
ceiling is
--size-wk-table-sticky-column-max,min(40vw, 16rem)— the same tokentablecaps with, so one value covers both. It is a cap, not a width: a short label keeps its own, and a table with room to spare wraps nothing. Only that column wraps; every other cell keeps its single line. - With
selectable, two columns freeze — the checkbox and the name it belongs to. A checkbox that scrolls out from under its own row is worse than one that never moved. - The start shadow stands down. It marks content that has scrolled away past that edge, and with a column frozen there nothing has. A shadow lying on the one column that does not move says the opposite of what it means. The end shadow stays: content really does continue there.
- The table is allowed to exceed its scroller. Without that it squeezes itself to fit, nothing ever overflows, and a frozen column freezes against an edge that never moves — the feature renders and does nothing.
The freeze is positioned logically, so under dir="rtl" the first column holds the right
edge without a second rule.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
loading |
bool | false |
A wait the table does not start itself, such as a queued import. While true the table sets aria-busy and announces once through the status region; the render that passes false ends it. The sorts and searches the table sends need no prop. See the loading state. |
rows |
array | [] |
Row objects. A date object in a row shows as PHP prints it, in its own time zone (1990-12-25 00:00:00), and sorts as the dates do; for another form, format the date in the row. A model's dates arrive in the form its serializeDate() gives |
columns |
array | [] |
Column definitions (see above) |
rowKey |
string | 'id' |
Unique id field (selection + morph keying). An integer id above Number.MAX_SAFE_INTEGER, such as a 64-bit id, travels as its digits, so the selection hands it back exactly |
selectable |
bool | false |
Per-row + select-all checkboxes |
stickyColumn |
bool | false |
Freezes the first column while the rest scrolls sideways. With selectable, the checkbox column freezes too. The frozen column wraps at --size-wk-table-sticky-column-max instead of taking the screen. |
searchable |
bool | false |
Toolbar search box |
density |
string | 'comfortable' |
comfortable · compact |
columnManager |
bool | false |
Show/hide-columns dropdown |
hidden |
array | [] |
Hidden column keys. A later render with a different list moves the table to it — see Keeping the reader's columns |
server |
bool | false |
Server-driven — stop local sort/filter, emit events only |
sortKey |
string|null | null |
The column the table starts sorted by. In server mode, the column the server already sorted by, so aria-sort and the arrow describe the rows on screen |
sortDir |
string | 'asc' |
asc or desc, the direction of that initial sort. Anything else is reported and reads as asc |
search |
string|null | null |
In server mode, the filter the server applied. It fills the search field on the first render and whenever the server changes it on its own; text the reader has typed since is kept. null leaves the field to the reader |
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 |
selected |
array|null | null |
The selection as your component holds it, a list of row keys. The first render opens with it, and after that the table takes it from a response that changed it; see After a bulk action |
allMatches |
bool|null | null |
Every match, as your component holds it, beside selected |
total |
int|null | null |
In server mode with selectable, the number of matches on all pages. Once the whole page is selected the bar offers every match; see Selecting every match |
filter |
string|array|null | null |
In server mode, anything that names a filter of yours beyond search. A render with another value, or another search, while every match is selected clears it |
name |
string|null | null |
Hidden-input name mirroring selected ids. Without an id the table's id derives from it; a second table with the same name on the page gets -2, so each is named by its own caption |
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
bulk, 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 noActions Label tabindex(it does not scroll), its buttons are in the tab order on their own, and the selection count is announced through the table's own status region 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 density toggle's two buttons carry
aria-pressed. The pressed one is a tint, and under forced colors, which paints no tint, it is framed in the system's selection color. - The select-all checkbox is tri-state (
indeterminatewhen a subset is selected). The selection count is announced through a status region that is in the page before the first row is selected, outside the bar, which only shows with a selection. In server mode withtotalit also says the offer of every match on all pages, and that every match is selected. - A
captionbecomes the table's accessible name. - An
aria-labeloraria-labelledbyof yours names the table and its scroll region, in place of the caption.
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 |
Frozen column cap (sticky-column) |
--size-wk-table-sticky-column-max |
Customization
Override the defaults via config/:
'components' => [
'data-table' => [
'density' => 'compact',
'selectable' => true,
'searchable' => true,
'sticky-column' => true,
],
],
A frozen cell carries its own opaque surface, so a theme that repaints rows has three keys to
repaint with it — sticky-cell, sticky-cell-selected and sticky-head-cell. They resolve only
while sticky-column is on.
WireKit::personalize('data-table', [
'sticky-cell' => fn (string $vendor): string => str_replace('bg-[var(--color-wk-bg)]', 'bg-[var(--color-wk-bg-subtle)]', $vendor),
'sticky-cell-selected' => 'bg-[color-mix(in_srgb,var(--color-wk-accent)_12%,var(--color-wk-bg))]',
'sticky-head-cell' => fn (string $vendor): string => str_replace('bg-[var(--color-wk-bg)]', 'bg-[var(--color-wk-bg-subtle)]', $vendor),
]);
The two cells take a closure because their blocks also hold sticky and the layer that keeps the frozen column above the rows scrolling under it, which a plain string would replace along with the surface. The selected fill is a single class, so there a string is the whole block. In a selected row a frozen cell wears sticky-cell without its backgrounds and the selected fill in their place, so the fill is what shows there, also under the pointer, as on the row itself.