Skip to main content
WireKit
Copy for LLM

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.

A sortable data table
Customers

No results

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.

Orders with status badges
Orders

No results

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.

Selectable rows with a bulk-action bar
Subscribers

No results

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.

Toolbar with search, density, and column manager
Users

No results

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.

A row-actions column
Invoices
Actions

No results

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.

Customer over email, total over currency

No results

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,
    },
]);
Out, low, and a plain count

No results

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.

Initials, name, email

No results

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.

An order reference that leads, and a SKU that lines up

No results

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.

None, one, and two

No results

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:

An empty table that says what to do next

No invoices yet

Your first invoice will appear here once you send it.

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 a role="region" pointed at that caption with aria-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 as landmark-unique. The caption is also the table's own sr-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 no tabindex (it does not scroll), its buttons are in the tab order on their own, and the selection count is announced through its aria-live span 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 (indeterminate when a subset is selected); the selection count is announced via an aria-live region.
  • A caption becomes 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,
    ],
],

Further Reading

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store