Table
A responsive data table with header, body, row, cell, sort-button, and shared announced loading and empty-state row parts; rows can link as a whole.
Preview
| Name | Role | |
|---|---|---|
| Jane Doe | [email protected] | Admin |
| Alex Brown | [email protected] | Member |
<x-ui.table class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Name') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Email') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Role') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Jane Doe') }}</x-ui.table.cell>
<x-ui.table.cell>jane@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Admin') }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Alex Brown') }}</x-ui.table.cell>
<x-ui.table.cell>alex@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Member') }}</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
Variant options
Density options
Align options
Installation
php artisan ui:add table
Note
This component ships an Alpine behavior module at
resources/js/ui/table.js. Import it once from your bundle so it registers on alpine:init:
import './table.js';
Registry contract
php artisan ui:add 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/table.blade.php -
resources/views/components/ui/table/header.blade.php -
resources/views/components/ui/table/body.blade.php -
resources/views/components/ui/table/row.blade.php -
resources/views/components/ui/table/head.blade.php -
resources/views/components/ui/table/sort-button.blade.php -
resources/views/components/ui/table/cell.blade.php -
resources/views/components/ui/table/state.blade.php -
resources/js/ui/table.js
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: Table (`table`)
A responsive data table with header, body, row, cell, sort-button, and shared announced loading and empty-state row parts; rows can link as a whole.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add table
```
## Usage
```blade
<x-ui.table class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Name') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Email') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Role') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Jane Doe') }}</x-ui.table.cell>
<x-ui.table.cell>[email protected]</x-ui.table.cell>
<x-ui.table.cell>{{ __('Admin') }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Alex Brown') }}</x-ui.table.cell>
<x-ui.table.cell>[email protected]</x-ui.table.cell>
<x-ui.table.cell>{{ __('Member') }}</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
```
## Props
- `variant` (default|striped|borderless|vertical-lines, default `default`) — Semantic row and border treatment.
- `density` (string, default `default`) — Declared by @props in the shipped Blade source.
- `sticky` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `bordered` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `align` (start|center|end, default `start`) — Documented catalog control used by the preview workbench.
- `href` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `nowrap` (bool, default `false`) — Declared by @props in the registry Blade source.
- `direction` (string, default `none`) — Declared by @props in the registry Blade source.
- `state` (string, default `empty`) — Declared by @props in the registry Blade source.
- `colspan` (int, default `1`) — Declared by @props in the registry Blade source.
- `heading` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `description` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
## Use when
- Use when users need exact comparison across rows and columns, plus sorting or filtering.
- Presenting tabular data in rows and columns with the base table primitives: table, table.header, table.row, table.cell, and related parts.
- Choosing a presentational variant, such as striped rows or column dividers, without adding sorting, filtering, or pagination.
## Avoid when
- Do not use a table for narrative browsing or small mobile contexts where cards or lists are easier to scan.
- Prefer when exact cross-item comparison is the core job, not merely browsing.
- Rows need client-side sorting, filtering, or selection; use data-table. Rows are paginated server-side with Livewire; use livewire-data-table.
## Anti-patterns
- Using cards when exact comparison is primary
- Forcing wide tables into narrow layouts without adaptation
## Rules
- Use the `<brok:table>` tag (or `<x-ui.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/table
- Registry JSON (files, props, contract): https://brokui.dev/r/open/table.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- `bordered`: a hairline around the whole table, rounded to the control tier.
The frame is the wrapper, so a width limit goes on a parent element. --}}
<div class="w-full max-w-md">
<x-ui.table bordered>
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Invoice') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Status') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Amount') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
@foreach ([['INV-001', 'Paid', '$250.00'], ['INV-002', 'Pending', '$150.00'], ['INV-003', 'Paid', '$350.00']] as [$id, $status, $amount])
<x-ui.table.row>
<x-ui.table.cell class="font-mono text-xs">{{ $id }}</x-ui.table.cell>
<x-ui.table.cell>{{ __($status) }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">{{ $amount }}</x-ui.table.cell>
</x-ui.table.row>
@endforeach
</x-ui.table.body>
</x-ui.table>
</div>
Borderless
<x-ui.table variant="borderless" class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Invoice') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Status') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Amount') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell class="font-medium">INV-1001</x-ui.table.cell>
<x-ui.table.cell>{{ __('Paid') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$250.00</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-medium">INV-1002</x-ui.table.cell>
<x-ui.table.cell>{{ __('Pending') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$150.00</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-medium">INV-1003</x-ui.table.cell>
<x-ui.table.cell>{{ __('Paid') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$350.00</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
Card Table
{{-- A record card: a borderless, dense key/value table. Labels sit in a fixed
muted column, values start-aligned beside them, so every card lines up. --}}
<div class="grid w-full max-w-lg gap-4 sm:grid-cols-2">
@foreach ([
['name' => 'Jane Doe', 'email' => 'jane@example.com', 'role' => 'Admin'],
['name' => 'Alex Brown', 'email' => 'alex@example.com', 'role' => 'Member'],
] as $person)
<x-ui.card class="p-6">
<p class="mb-2 text-sm font-semibold text-foreground">{{ __($person['name']) }}</p>
<x-ui.table variant="borderless" density="dense">
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.head scope="row" class="w-20 !ps-0 font-normal">{{ __('Email') }}</x-ui.table.head>
<x-ui.table.cell class="!pe-0 text-foreground">{{ $person['email'] }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.head scope="row" class="w-20 !ps-0 font-normal">{{ __('Role') }}</x-ui.table.head>
<x-ui.table.cell class="!pe-0 text-foreground">{{ __($person['role']) }}</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
</x-ui.card>
@endforeach
</div>
<x-ui.table density="dense" class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('SKU') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Product') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Stock') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell class="font-mono text-xs">SKU-001</x-ui.table.cell>
<x-ui.table.cell>{{ __('Wireless Mouse') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">128</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-mono text-xs">SKU-002</x-ui.table.cell>
<x-ui.table.cell>{{ __('Mechanical Keyboard') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">64</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-mono text-xs">SKU-003</x-ui.table.cell>
<x-ui.table.cell>{{ __('USB-C Hub') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">256</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-mono text-xs">SKU-004</x-ui.table.cell>
<x-ui.table.cell>{{ __('Webcam 1080p') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">42</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
{{-- Whole-row links. Each row keeps one real link in its name cell for
keyboard and screen readers; a click anywhere else on the row follows it,
while the checkbox and the row button keep their own behaviour. --}}
<div x-data="{ archived: '' }" class="flex w-full max-w-xl flex-col gap-3">
<x-ui.table class="w-full text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head class="w-10"><span class="sr-only">{{ __('Select') }}</span></x-ui.table.head>
<x-ui.table.head>{{ __('Client') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Open invoices') }}</x-ui.table.head>
<x-ui.table.head align="end"><span class="sr-only">{{ __('Actions') }}</span></x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
@foreach ([['id' => 'atlas-bouw', 'name' => __('Atlas Bouw'), 'open' => 3], ['id' => 'noord-studio', 'name' => __('Noord Studio'), 'open' => 0]] as $client)
<x-ui.table.row :href="'#'.$client['id']">
<x-ui.table.cell>
<x-ui.checkbox :aria-label="__('Select :name', ['name' => $client['name']])" />
</x-ui.table.cell>
<x-ui.table.cell>
<a href="#{{ $client['id'] }}" data-row-link class="rounded-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ $client['name'] }}</a>
</x-ui.table.cell>
<x-ui.table.cell class="tabular-nums text-muted-foreground">{{ $client['open'] }}</x-ui.table.cell>
<x-ui.table.cell align="end">
<x-ui.button variant="ghost" size="sm" x-on:click="archived = {{ \Illuminate\Support\Js::from($client['name']) }}">{{ __('Archive') }}</x-ui.button>
</x-ui.table.cell>
</x-ui.table.row>
@endforeach
</x-ui.table.body>
</x-ui.table>
<p class="text-sm text-muted-foreground" role="status" x-text="archived ? @js(__('Archived:')) + ' ' + archived : ''"></p>
</div>
Sticky Header
<x-ui.table :sticky="true" class="w-full max-w-lg max-h-64 text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Name') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Email') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Role') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
@foreach ([
['Jane Doe', 'jane@example.com', 'Admin'],
['Alex Brown', 'alex@example.com', 'Member'],
['Maria Garcia', 'maria@example.com', 'Editor'],
['Liam Smith', 'liam@example.com', 'Member'],
['Noah Johnson', 'noah@example.com', 'Member'],
['Olivia Lee', 'olivia@example.com', 'Admin'],
['Emma Wilson', 'emma@example.com', 'Editor'],
['Sofia Martin', 'sofia@example.com', 'Member'],
] as [$name, $email, $role])
<x-ui.table.row>
<x-ui.table.cell>{{ __($name) }}</x-ui.table.cell>
<x-ui.table.cell>{{ $email }}</x-ui.table.cell>
<x-ui.table.cell>{{ __($role) }}</x-ui.table.cell>
</x-ui.table.row>
@endforeach
</x-ui.table.body>
</x-ui.table>
<x-ui.table variant="striped" class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Name') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Email') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Role') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Jane Doe') }}</x-ui.table.cell>
<x-ui.table.cell>jane@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Admin') }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Alex Brown') }}</x-ui.table.cell>
<x-ui.table.cell>alex@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Member') }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Maria Garcia') }}</x-ui.table.cell>
<x-ui.table.cell>maria@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Editor') }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Liam Smith') }}</x-ui.table.cell>
<x-ui.table.cell>liam@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Member') }}</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
{{-- A vertical (key/value) table: row headers in a muted column, every cell
middle-aligned so labels and values sit on one line. --}}
<div class="w-full max-w-md">
<x-ui.table variant="vertical-lines" bordered>
<x-ui.table.body>
@foreach ([
['Name', 'Jane Doe'],
['Email', 'jane@example.com'],
['Role', 'Administrator'],
['Status', 'Active'],
] as [$label, $value])
<x-ui.table.row>
<x-ui.table.head scope="row" class="w-1/3 bg-muted/40 font-normal">{{ __($label) }}</x-ui.table.head>
<x-ui.table.cell>{{ __($value) }}</x-ui.table.cell>
</x-ui.table.row>
@endforeach
</x-ui.table.body>
</x-ui.table>
</div>
Vertical Lines
<x-ui.table variant="vertical-lines" class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Region') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Q1') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Q2') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Q3') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell class="font-medium">{{ __('North') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$12,400</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$14,200</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$15,800</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-medium">{{ __('South') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$9,100</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$10,500</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$11,300</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell class="font-medium">{{ __('West') }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$18,700</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$19,900</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums">$21,400</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
With Images
<x-ui.table class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('File') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Owner') }}</x-ui.table.head>
<x-ui.table.head align="end">{{ __('Size') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
@foreach ([
['Quarterly Report.pdf', 'Jane Doe', '2.4 MB'],
['Brand Assets.zip', 'Alex Brown', '18.1 MB'],
['Roadmap.sketch', 'Maria Garcia', '6.7 MB'],
] as [$file, $owner, $size])
<x-ui.table.row>
<x-ui.table.cell>
<span class="flex items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
</span>
<span class="font-medium">{{ $file }}</span>
</span>
</x-ui.table.cell>
<x-ui.table.cell class="text-muted-foreground">{{ __($owner) }}</x-ui.table.cell>
<x-ui.table.cell align="end" class="tabular-nums text-muted-foreground">{{ $size }}</x-ui.table.cell>
</x-ui.table.row>
@endforeach
</x-ui.table.body>
</x-ui.table>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | default | striped | borderless | vertical-lines | default | Semantic row and border treatment. |
| density | string | default | Declared by @props in the shipped Blade source. |
| sticky | bool | false | Declared by @props in the shipped Blade source. |
| bordered | bool | false | Declared by @props in the shipped Blade source. |
| align | start | center | end | start | Documented catalog control used by the preview workbench. |
| href | mixed | null | null | Declared by @props in the registry Blade source. |
| nowrap | bool | false | Declared by @props in the registry Blade source. |
| direction | string | none | Declared by @props in the registry Blade source. |
| state | string | empty | Declared by @props in the registry Blade source. |
| colspan | int | 1 | Declared by @props in the registry Blade source. |
| heading | mixed | null | null | Declared by @props in the registry Blade source. |
| description | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default—x-ui.table.header— Installed subcomponent from the registry item.x-ui.table.body— Installed subcomponent from the registry item.x-ui.table.row— Installed subcomponent from the registry item.x-ui.table.head— Installed subcomponent from the registry item.x-ui.table.sort-button— Installed subcomponent from the registry item.x-ui.table.cell— Installed subcomponent from the registry item.x-ui.table.state— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- sticky keeps the header visible while the wrapper scrolls vertically; a height must be set on the table via class for it to take effect.
- bordered draws a hairline around the whole table and rounds its corners to the control tier.
- A row with href forwards a click anywhere on the row to its link (the a[data-row-link], else the a[href] with the same URL, else the first link), so wire:navigate and other link handlers still run. Ctrl, Cmd, Shift and middle click open the URL in a new tab.
- Clicks on buttons, inputs, labels, selects, menus, other links and elements marked data-row-link-ignore inside a linked row keep their own behaviour, and a click that ends a text selection does not navigate.
- Preserves native table semantics for assistive technology.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive.
Guidance
Compare structured records across consistent attributes.
Use when
- Use when users need exact comparison across rows and columns, plus sorting or filtering.
- Presenting tabular data in rows and columns with the base table primitives: table, table.header, table.row, table.cell, and related parts.
- Choosing a presentational variant, such as striped rows or column dividers, without adding sorting, filtering, or pagination.
Avoid when
- Do not use a table for narrative browsing or small mobile contexts where cards or lists are easier to scan.
- Prefer when exact cross-item comparison is the core job, not merely browsing.
- Rows need client-side sorting, filtering, or selection; use data-table. Rows are paginated server-side with Livewire; use livewire-data-table.
Use instead
- List or cards for narrative browsing
Anti-patterns
- Using cards when exact comparison is primary
- Forcing wide tables into narrow layouts without adaptation
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- table.head renders scope="col" headers, and table.sort-button exposes aria-sort so a sortable column's direction is announced.
- A linked row stays a plain table row with one real link in its primary cell: keyboard users Tab to that link and press Enter, and screen readers hear one link per row, never a row turned into a link around other controls.
- The row highlights while its link has focus (focus-within), and the link keeps its own focus ring.
- 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
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="table-{{ $record->id }}">
<x-ui.table class="w-full max-w-lg text-start">
<x-ui.table.header>
<x-ui.table.row>
<x-ui.table.head>{{ __('Name') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Email') }}</x-ui.table.head>
<x-ui.table.head>{{ __('Role') }}</x-ui.table.head>
</x-ui.table.row>
</x-ui.table.header>
<x-ui.table.body>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Jane Doe') }}</x-ui.table.cell>
<x-ui.table.cell>jane@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Admin') }}</x-ui.table.cell>
</x-ui.table.row>
<x-ui.table.row>
<x-ui.table.cell>{{ __('Alex Brown') }}</x-ui.table.cell>
<x-ui.table.cell>alex@example.com</x-ui.table.cell>
<x-ui.table.cell>{{ __('Member') }}</x-ui.table.cell>
</x-ui.table.row>
</x-ui.table.body>
</x-ui.table>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok: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 files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Presentational variant. Interactive features (sorting, filtering,
// pagination, selection, …) live in the richer `data-table` component.
// default — bordered rows (existing look)
// striped — zebra-striped body rows
// borderless — no row dividers ("without dividers")
// vertical-lines — column dividers as well as row dividers
'variant' => 'default',
// Row padding scale: 'default' or 'dense' (compact).
'density' => 'default',
// Sticky header: keeps <thead> visible while the wrapper scrolls vertically.
// When true, set a height on the table via class (e.g. max-h-80) for it to bite.
'sticky' => false,
// A hairline around the whole table (the wrapper), rounded to the control tier.
'bordered' => false,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$table = $styles['table'] ?? [];
$variant = array_key_exists($variant, $table['variants'] ?? []) ? $variant : 'default';
$density = array_key_exists($density, $table['density'] ?? []) ? $density : 'default';
$tableClass = trim(implode(' ', array_filter([
$table['base'] ?? 'w-full caption-bottom text-sm',
$table['variants'][$variant] ?? '',
$table['density'][$density] ?? '',
$sticky ? ($table['sticky'] ?? '') : '',
$bordered ? '[&_tbody_tr:last-child]:border-b-0' : '',
])));
@endphp
<div
data-slot="table-wrapper"
@class([
'relative w-full max-w-full overflow-x-auto overscroll-x-contain',
'overflow-y-auto' => $sticky,
'rounded-md border border-border' => $bordered,
])
@if ($bordered) data-bordered="true" @endif
>
<table
data-slot="table"
data-variant="{{ $variant }}"
data-density="{{ $density }}"
@if ($sticky) data-sticky="true" @endif
{{ $attributes->merge(['class' => $tableClass]) }}
>
{{ $slot }}
</table>
</div>
<thead
data-slot="table-header"
{{ $attributes->merge(['class' => '[&_tr]:border-b [&_tr]:border-border']) }}
>
{{ $slot }}
</thead>
<tbody
data-slot="table-body"
{{ $attributes->merge(['class' => '[&_tr:last-child]:border-0']) }}
>
{{ $slot }}
</tbody>
@props([
// Makes the whole row a pointer target for this URL (Fitts). The row keeps
// one real link for keyboard and screen readers: put an <a> with the same
// href (or `data-row-link`) in the primary cell. Clicks elsewhere on the
// row forward to that link; buttons, inputs and menus in other cells keep
// working. Null keeps a plain row.
'href' => null,
])
@php
$href = filled($href) ? (string) $href : null;
@endphp
<tr
data-slot="table-row"
@if ($href)
data-href="{{ $href }}"
x-data="uiTableRow({ href: @js($href) })"
x-on:click="open($event)"
x-on:auxclick="open($event)"
@endif
{{ $attributes->merge(['class' => 'border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted'.($href ? ' cursor-pointer focus-within:bg-muted/50 motion-reduce:transition-none' : '')]) }}
>
{{ $slot }}
</tr>
@props([
// Text alignment for the header cell. Numeric and action columns need
// `end` (or `center`) without relying on a consumer-supplied Tailwind
// class to out-order the `text-start` this recipe used to bake in
// unconditionally — generated stylesheet order is not a tie-break
// guarantee (see CLAUDE.md "Consumer classes are appended through
// Laravel's attribute bag"). `start` reproduces the previous
// hardcoded output exactly.
'align' => 'start',
// Keep the header label on one line — useful next to a sort button or a
// narrow numeric column where wrapping would misalign the column.
'nowrap' => false,
])
@php
$alignClasses = [
'start' => 'text-start',
'center' => 'text-center',
'end' => 'text-end',
];
$alignClass = $alignClasses[$align] ?? $alignClasses['start'];
@endphp
<th
data-slot="table-head"
data-align="{{ $align }}"
{{ $attributes->merge([
'class' => trim(
'h-10 px-3 '.$alignClass.' align-middle text-xs font-medium text-muted-foreground [&:has([role=checkbox])]:w-px'
.($nowrap ? ' whitespace-nowrap' : '')
),
]) }}
>
{{ $slot }}
</th>
@props([
'direction' => 'none',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$table = $styles['table'];
$resolvedDirection = in_array($direction, ['none', 'ascending', 'descending'], true) ? $direction : 'none';
@endphp
<button
type="button"
data-slot="table-sort-button"
data-direction="{{ $resolvedDirection }}"
{{ $attributes->merge(['class' => $table['sortButton']]) }}
>
<span class="min-w-0 truncate">{{ $slot }}</span>
<span class="shrink-0 text-muted-foreground/70 group-hover/sort:text-foreground" aria-hidden="true">
<svg class="size-4 group-data-[direction=ascending]/sort:hidden group-data-[direction=descending]/sort:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
<svg class="hidden size-4 group-data-[direction=ascending]/sort:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6" />
</svg>
<svg class="hidden size-4 group-data-[direction=descending]/sort:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
@props([
// Text alignment for the cell. Unlike `table.head`, the default recipe
// never baked in a `text-*` utility, so there was no stylesheet-order
// hazard here — a consumer's own `class="text-end"` already won reliably.
// The prop is added anyway so a numeric/action column can align its head
// and cells through the same API instead of a prop on one and a raw
// class on the other. `start` renders no alignment class, exactly
// matching the previous (alignment-unset) output.
'align' => 'start',
'nowrap' => false,
])
@php
$alignClasses = [
'center' => 'text-center',
'end' => 'text-end',
];
$alignClass = $alignClasses[$align] ?? '';
@endphp
<td
data-slot="table-cell"
data-align="{{ $align }}"
{{ $attributes->merge([
'class' => trim(
'px-3 py-2 align-middle'
.($alignClass !== '' ? ' '.$alignClass : '')
.($nowrap ? ' whitespace-nowrap' : '')
),
]) }}
>
{{ $slot }}
</td>
@props([
'state' => 'empty',
'colspan' => 1,
'heading' => null,
'description' => null,
])
@php
$isLoading = $state === 'loading';
$heading ??= $isLoading ? 'Loading data…' : 'No results';
$description ??= $isLoading
? 'Wait while the latest data loads.'
: 'Try adjusting your search or filters.';
@endphp
<tr
data-slot="table-state"
data-state="{{ $isLoading ? 'loading' : 'empty' }}"
aria-live="polite"
aria-atomic="true"
{{ $attributes }}
>
<td colspan="{{ max(1, (int) $colspan) }}" class="px-3 py-12 text-center">
<div class="mx-auto flex max-w-sm flex-col items-center gap-2">
<span class="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground" aria-hidden="true">
@if ($isLoading)
<x-ui.spinner class="size-5" />
@else
<svg class="size-5" 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>
@endif
</span>
<p class="text-sm font-medium text-foreground">{{ __($heading) }}</p>
<p class="text-sm text-muted-foreground">{{ __($description) }}</p>
@if (! $slot->isEmpty())
<div class="mt-2">{{ $slot }}</div>
@endif
</div>
</td>
</tr>
/**
* Table row link (`<x-ui.table.row href="…">`).
*
* The whole row is a pointer target, but the row keeps one real link: the
* anchor in its primary cell (`data-row-link`, else the first `a[href]`).
* Keyboard and screen-reader users reach that link as usual; a click on the
* rest of the row forwards to it, so `wire:navigate` and other link handlers
* still run. Clicks on buttons, inputs, labels, menus and other links inside
* the row keep their own behaviour, and selecting text never navigates.
*/
const INTERACTIVE = [
'a[href]',
'button',
'input',
'select',
'textarea',
'label',
'summary',
'details',
'[contenteditable]:not([contenteditable="false"])',
'[role="button"]',
'[role="checkbox"]',
'[role="switch"]',
'[role="menuitem"]',
'[role="option"]',
'[role="link"]',
'[tabindex]:not([tabindex="-1"])',
'[data-row-link-ignore]',
].join(',');
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTableRow', (config = {}) => ({
href: config.href ?? '',
link() {
return this.$el.querySelector('a[data-row-link]')
?? Array.from(this.$el.querySelectorAll('a[href]')).find((anchor) => anchor.getAttribute('href') === this.href)
?? this.$el.querySelector('a[href]');
},
ignore(event) {
const target = event.target instanceof Element ? event.target : null;
if (!target || !this.$el.contains(target)) return true;
const interactive = target.closest(INTERACTIVE);
if (interactive && interactive !== this.$el && this.$el.contains(interactive)) return true;
// A drag that selected text is a selection, not a click.
const selection = window.getSelection?.();
return Boolean(selection && !selection.isCollapsed && String(selection).trim() !== '' && this.$el.contains(selection.anchorNode));
},
open(event) {
if (event.button > 1 || event.defaultPrevented || this.ignore(event)) return;
const anchor = this.link();
const url = anchor?.href ?? this.href;
if (!url) return;
// A modifier or middle click opens a new tab, as it would on the link.
if (event.button === 1 || event.metaKey || event.ctrlKey || event.shiftKey) {
event.preventDefault();
window.open(url, '_blank', 'noopener');
return;
}
if (event.button !== 0) return;
if (anchor) {
anchor.click();
} else {
window.location.assign(url);
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.7.3 - 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