Skip to content
Brok UI

Loading…

No results

Livewire Data Table

Open source

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.

Version
v1.4.7
Stability
stable
License
MIT
Related
Data Table
Pagination
Admin Table

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.

previews.components.livewire-data-table.default.blade.php Blade
@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

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

  • blade resources/views/components/ui/livewire-data-table/livewire-data-table.blade.php
Registry dependencies
table input select checkbox
Packages
composer: jml/brok:^0.2

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.

livewire-data-table.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

livewire-data-table

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

Scannable data display

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
livewire-data-table
Theming hooks
livewire-data-table

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Safe

Livewire can update this component through forwarded wire:* attributes.

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/livewire-data-table/livewire-data-table.blade.php Blade
{{--
    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