Skip to main content
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. 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.

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, so 'intents' => ['processing' => 'accent'] is as valid as success or danger.

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

Selectable rows with a bulk-action bar
Subscribers

No results

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.

Toolbar with search, density, and column manager
Users

No results

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>
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()"
    {{-- 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.set('search', $event.detail.value, false) 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="selectedIds" 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.

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 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 cell that links to its page
Feedback by page

No results

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:

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

Months across, regions down
Revenue by region

No results

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.

The region stays while the months scroll
Revenue by region, first column frozen

No results

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 token table caps 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 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 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 (indeterminate when 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 with total it also says the offer of every match on all pages, and that every match is selected.
  • A caption becomes the table's accessible name.
  • An aria-label or aria-labelledby of 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/wirekit.php:

'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.

Further Reading

Updated in WireKit v2.67.0 (2026-10-15)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store