Livewire Data Table
A server-driven Livewire 3/4 data table with allowlisted sorting guidance, debounced search, per-page selection, paginator links, shared loading and empty rows, optional row selection, and mandatory stable row keys. It remains readable without Livewire.
Preview
| Role | ||
|---|---|---|
| Ada Lovelace | [email protected] | Owner |
| Alan Turing | [email protected] | Admin |
| Grace Hopper | [email protected] | Member |
|
Updating results… Wait while the latest data loads. |
||
@php
$columns = [
['key' => 'name', 'label' => 'Name'],
['key' => 'email', 'label' => 'Email'],
['key' => 'role', 'label' => 'Role', 'sortable' => false],
];
$rows = [
['id' => 1, 'name' => 'Ada Lovelace', 'email' => 'ada@example.com', 'role' => 'Owner'],
['id' => 2, 'name' => 'Alan Turing', 'email' => 'alan@example.com', 'role' => 'Admin'],
['id' => 3, 'name' => 'Grace Hopper', 'email' => 'grace@example.com', 'role' => 'Member'],
];
@endphp
<div class="w-full">
<x-ui.livewire-data-table :columns="$columns" :rows="$rows" sort-field="name" sort-direction="asc" />
</div>
Installation
php artisan ui:add livewire-data-table
Registry contract
php artisan ui:add livewire-data-table
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/livewire-data-table/livewire-data-table.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Livewire Data Table (`livewire-data-table`)
A server-driven Livewire 3/4 data table with allowlisted sorting guidance, debounced search, per-page selection, paginator links, shared loading and empty rows, optional row selection, and mandatory stable row keys. It remains readable without Livewire.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add livewire-data-table
```
## Usage
```blade
@php
$columns = [
['key' => 'name', 'label' => 'Name'],
['key' => 'email', 'label' => 'Email'],
['key' => 'role', 'label' => 'Role', 'sortable' => false],
];
$rows = [
['id' => 1, 'name' => 'Ada Lovelace', 'email' => '[email protected]', 'role' => 'Owner'],
['id' => 2, 'name' => 'Alan Turing', 'email' => '[email protected]', 'role' => 'Admin'],
['id' => 3, 'name' => 'Grace Hopper', 'email' => '[email protected]', 'role' => 'Member'],
];
@endphp
<div class="w-full">
<x-ui.livewire-data-table :columns="$columns" :rows="$rows" sort-field="name" sort-direction="asc" />
</div>
```
## Props
- `columns` (array, default `[]`) — Declared by @props in the shipped Blade source.
- `rows` (array, default `[]`) — Declared by @props in the shipped Blade source.
- `searchModel` (string, default `search`) — Declared by @props in the shipped Blade source.
- `sortField` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
- `sortDirection` (string, default `asc`) — Declared by @props in the shipped Blade source.
- `sortMethod` (string, default `sortBy`) — Declared by @props in the shipped Blade source.
- `perPageModel` (string, default `perPage`) — Declared by @props in the shipped Blade source.
- `perPageOptions` (array, default `[10, 15, 25, 50, 100]`) — Declared by @props in the shipped Blade source.
- `selectable` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `selectedModel` (string, default `selected`) — Declared by @props in the shipped Blade source.
- `rowKey` (string, default `id`) — Declared by @props in the shipped Blade source.
- `rowKeyPrefix` (string, default `row`) — Declared by @props in the shipped Blade source.
- `searchPlaceholder` (string, default `Search…`) — Declared by @props in the shipped Blade source.
- `emptyHeading` (string, default `No results`) — Declared by @props in the shipped Blade source.
- `emptyDescription` (string, default `Try adjusting your search or filters.`) — Declared by @props in the shipped Blade source.
- `maxPageRows` (int, default `100`) — Maximum rows allowed in one server-rendered page.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- The complete dataset is larger than the Data Table client-side row limit.
- Search, sorting, and pagination must run on the server.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Apply the data-table guidance and preserve state, focus, and meaningful loading feedback across server updates.
- All rows are already present and the dataset stays within the client-side boundary; use data-table instead.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:livewire-data-table>` tag (or `<x-ui.livewire-data-table>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/livewire-data-table
- Registry JSON (files, props, contract): https://brokui.dev/r/open/livewire-data-table.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | array | [] | Declared by @props in the shipped Blade source. |
| rows | array | [] | Declared by @props in the shipped Blade source. |
| searchModel | string | search | Declared by @props in the shipped Blade source. |
| sortField | mixed | null | null | Declared by @props in the shipped Blade source. |
| sortDirection | string | asc | Declared by @props in the shipped Blade source. |
| sortMethod | string | sortBy | Declared by @props in the shipped Blade source. |
| perPageModel | string | perPage | Declared by @props in the shipped Blade source. |
| perPageOptions | array | [10, 15, 25, 50, 100] | Declared by @props in the shipped Blade source. |
| selectable | bool | false | Declared by @props in the shipped Blade source. |
| selectedModel | string | selected | Declared by @props in the shipped Blade source. |
| rowKey | string | id | Declared by @props in the shipped Blade source. |
| rowKeyPrefix | string | row | Declared by @props in the shipped Blade source. |
| searchPlaceholder | string | Search… | Declared by @props in the shipped Blade source. |
| emptyHeading | string | No results | Declared by @props in the shipped Blade source. |
| emptyDescription | string | Try adjusting your search or filters. | Declared by @props in the shipped Blade source. |
| maxPageRows | int | 100 | Maximum rows allowed in one server-rendered page. |
Slots
x-ui.livewire-data-table.livewire-data-table— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Requires server pagination for large datasets and rejects more than 100 rendered rows per page by default.
- Keeps sorting allowlisted, row keys stable, page sizes bounded, and content readable without JavaScript.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, requires.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- The complete dataset is larger than the Data Table client-side row limit.
- Search, sorting, and pagination must run on the server.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Apply the data-table guidance and preserve state, focus, and meaningful loading feedback across server updates.
- All rows are already present and the dataset stays within the client-side boundary; use data-table instead.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Sortable column headers announce direction through aria-sort, kept in sync with the Livewire sortField/sortDirection state.
- Loading round-trips disable interactive controls and dim the table with wire:loading rather than removing focusable elements.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:livewire-data-table
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Livewire Data Table — server-driven sort / search / pagination.
This component is meant to be DROPPED INTO a Livewire 3 or 4 component's view. It
renders no state of its own; every interaction is a wire: round-trip to the
parent. The columns and the current page of rows are passed in as props, so
the same markup is reusable across resources.
── Expected Livewire parent contract ──────────────────────────────────────
The host Livewire component MUST expose:
public string $search = ''; // bound by the search box
#[Livewire\Attributes\Locked]
public string $sortField = 'id'; // current sort column key
#[Livewire\Attributes\Locked]
public string $sortDirection = 'asc'; // 'asc' | 'desc'
public int $perPage = 15; // bound by the per-page select
private const SORTABLE_FIELDS = ['id', 'name', 'email'];
// Toggle sort field / flip direction. Reset the paginator on sort.
public function sortBy(string $field): void
{
abort_unless(in_array($field, self::SORTABLE_FIELDS, true), 400);
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
}
private const PER_PAGE_OPTIONS = [10, 15, 25, 50, 100];
public function updatedPerPage(int $value): void
{
abort_unless(in_array($value, self::PER_PAGE_OPTIONS, true), 400);
$this->resetPage();
}
use Livewire\WithPagination; // for resetPage() + {{ $rows->links() }}
// When `:selectable` is true, also expose a selection model:
public array $selected = []; // wire:model="selected"
// (and typically a $selectAll boolean + updatedSearch()/updatedPerPage()
// hooks that resetPage()).
public function render()
{
$rows = Model::query()
->when($this->search, fn ($q) =>
$q->where('name', 'like', "%{$this->search}%"))
->orderBy($this->sortField, $this->sortDirection)
->paginate($this->perPage);
return view('livewire.example', [
'columns' => [
['key' => 'name', 'label' => 'Name', 'sortable' => true],
['key' => 'email', 'label' => 'Email', 'sortable' => true],
['key' => 'role', 'label' => 'Role', 'sortable' => false],
],
'rows' => $rows,
]);
}
Then in the view: <brok:livewire-data-table :columns="$columns" :rows="$rows" />
Without Livewire the headers still render as real <button>s with correct
aria-sort and the table is fully readable — only the wire: round-trips are
inert.
--}}
@props([
// Array of column descriptors. Each: ['key' => string, 'label' => string,
// 'sortable' => bool (default true), 'align' => 'start'|'center'|'end'].
'columns' => [],
// The current page of rows. Expected to be an Eloquent LengthAwarePaginator
// (so $rows->links() renders) but any iterable of array|object rows
// works for the table body.
'rows' => [],
// Live Livewire properties / methods on the parent (override to rename).
'searchModel' => 'search',
'sortField' => null, // current sort column key (drives aria-sort)
'sortDirection' => 'asc', // 'asc' | 'desc'
'sortMethod' => 'sortBy', // wire method invoked on a header click
'perPageModel' => 'perPage',
'perPageOptions' => [10, 15, 25, 50, 100],
// Opt-in row-selection checkboxes (wire:model="selected" on each row).
'selectable' => false,
'selectedModel' => 'selected',
// Required stable identity path for every row. Use uuid or another immutable
// key when the row has no id property.
'rowKey' => 'id',
// Prefix keys when two tables can render inside the same Livewire root.
'rowKeyPrefix' => 'row',
'searchPlaceholder' => 'Search…',
'emptyHeading' => 'No results',
'emptyDescription' => 'Try adjusting your search or filters.',
'maxPageRows' => 100,
])
@php
// Normalise columns to a stable shape.
$cols = collect($columns)->map(fn ($c) => [
'key' => $c['key'] ?? '',
'label' => $c['label'] ?? ($c['key'] ?? ''),
'sortable' => $c['sortable'] ?? true,
'align' => in_array($c['align'] ?? 'start', ['start', 'center', 'end'], true) ? ($c['align'] ?? 'start') : 'start',
])->all();
$alignClass = ['start' => 'text-start', 'center' => 'text-center', 'end' => 'text-end'];
$colspan = count($cols) + ($selectable ? 1 : 0);
if ((is_countable($rows) ? count($rows) : 0) > (int) $maxPageRows) {
throw new \InvalidArgumentException(__('Livewire Data Table received more than :count rows for one page. Use server pagination and a bounded page size.', ['count' => (int) $maxPageRows]));
}
// Does $rows expose paginator links()? (false under the inert preview path.)
$hasPaginator = is_object($rows) && method_exists($rows, 'links');
$rowItems = is_object($rows) && method_exists($rows, 'items') ? $rows->items() : $rows;
$requestTargets = collect([$searchModel, $perPageModel, $sortMethod, $selectedModel])
->filter(fn ($target) => filled($target))
->unique()
->implode(', ');
@endphp
<div
data-slot="livewire-data-table"
wire:loading.class="opacity-70"
wire:target="{{ $requestTargets }}"
{{ $attributes->class('w-full space-y-4 transition-opacity duration-150 ease-out motion-reduce:transition-none') }}
>
{{-- Toolbar: search + per-page --}}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:max-w-xs">
<span class="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-muted-foreground" aria-hidden="true">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<x-ui.input
type="search"
wire:model.live.debounce.300ms="{{ $searchModel }}"
:placeholder="$searchPlaceholder"
:aria-label="$searchPlaceholder"
autocomplete="off"
class="ps-9"
/>
</div>
<label class="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span>{{ __('Per page') }}</span>
<x-ui.select
wire:model.live="{{ $perPageModel }}"
wire:loading.attr="disabled"
wire:target="{{ $perPageModel }}"
class="w-auto"
>
@foreach ($perPageOptions as $option)
<option value="{{ $option }}">{{ $option }}</option>
@endforeach
</x-ui.select>
</label>
</div>
{{-- Table --}}
<x-ui.table class="border-collapse">
<thead class="border-b border-border">
<tr>
@if ($selectable)
<th scope="col" class="w-10 px-3 py-2 text-start">
<span class="sr-only">{{ __('Select') }}</span>
</th>
@endif
@foreach ($cols as $col)
@php
$isSorted = filled($sortField) && $sortField === $col['key'];
$ariaSort = ! $col['sortable']
? null
: ($isSorted ? ($sortDirection === 'desc' ? 'descending' : 'ascending') : 'none');
@endphp
<th
scope="col"
@if ($ariaSort) aria-sort="{{ $ariaSort }}" @endif
class="px-3 py-2 text-xs font-medium text-muted-foreground {{ $alignClass[$col['align']] }}"
>
@if ($col['sortable'])
<x-ui.table.sort-button
:direction="$ariaSort"
wire:click="{{ $sortMethod }}({{ \Illuminate\Support\Js::from($col['key']) }})"
wire:loading.attr="disabled"
wire:target="{{ $sortMethod }}"
:aria-current="$isSorted ? 'true' : null"
>
{{ __($col['label']) }}
</x-ui.table.sort-button>
@else
<span>{{ __($col['label']) }}</span>
@endif
</th>
@endforeach
</tr>
</thead>
<tbody wire:loading.remove wire:target="{{ $requestTargets }}">
@forelse ($rowItems as $row)
@php
$resolvedRowKey = data_get($row, $rowKey);
if ($resolvedRowKey === null || $resolvedRowKey === '') {
throw new \InvalidArgumentException("livewire-data-table requires a stable [{$rowKey}] value for every row.");
}
@endphp
<tr wire:key="{{ $rowKeyPrefix }}-{{ $resolvedRowKey }}" class="border-b border-border transition-colors hover:bg-muted/50">
@if ($selectable)
<td class="px-3 py-2 align-middle">
<x-ui.checkbox
wire:model.live="{{ $selectedModel }}"
wire:loading.attr="disabled"
wire:target="{{ $selectedModel }}"
value="{{ $resolvedRowKey }}"
:aria-label="__('Select row')"
/>
</td>
@endif
@foreach ($cols as $col)
@php $value = is_array($row) ? ($row[$col['key']] ?? null) : ($row->{$col['key']} ?? null); @endphp
<td class="px-3 py-2 align-middle text-sm text-foreground {{ $alignClass[$col['align']] }}">
{{ $value }}
</td>
@endforeach
</tr>
@empty
<x-ui.table.state
state="empty"
:colspan="$colspan"
:heading="$emptyHeading"
:description="$emptyDescription"
/>
@endforelse
</tbody>
<tbody>
<x-ui.table.state
state="loading"
:colspan="$colspan"
:heading="__('Updating results…')"
wire:loading.delay.shorter
wire:target="{{ $requestTargets }}"
/>
</tbody>
</x-ui.table>
{{-- Footer: pagination --}}
@if ($hasPaginator)
<div class="flex items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
{{ __('Showing :from–:to of :total', ['from' => $rows->firstItem() ?? 0, 'to' => $rows->lastItem() ?? 0, 'total' => $rows->total()]) }}
</p>
<div>
{{ $rows->links() }}
</div>
</div>
@endif
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.7 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0