Skip to content
Brok UI

Loading…

No results

Table

Open source Core

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.

Version
v1.7.3
Stability
stable
License
MIT
Related
Data Table
Livewire Data Table
Admin Table

Preview

Name Email Role
Jane Doe [email protected] Admin
Alex Brown [email protected] Member
Variant
Density
Align
previews.components.table.default.blade.php 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>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

Default Current
Striped Current
Borderless Current
Vertical lines Current

Density options

Default Current
Dense Current
Comfortable Current

Align options

Start Current
Center Current
End Current

Installation

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

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/table.blade.php
  • blade resources/views/components/ui/table/header.blade.php
  • blade resources/views/components/ui/table/body.blade.php
  • blade resources/views/components/ui/table/row.blade.php
  • blade resources/views/components/ui/table/head.blade.php
  • blade resources/views/components/ui/table/sort-button.blade.php
  • blade resources/views/components/ui/table/cell.blade.php
  • blade resources/views/components/ui/table/state.blade.php
  • js resources/js/ui/table.js
Registry dependencies
spinner
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

table.md
# 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.blade.php Blade
{{-- `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.blade.php Blade
<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.blade.php Blade
{{-- 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>
dense.blade.php Blade
<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>
sticky-header.blade.php Blade
<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>
striped.blade.php Blade
<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>
vertical.blade.php Blade
{{-- 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.blade.php Blade
<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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

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

table table-body table-cell table-head table-header table-row table-sort-button table-state table-wrapper

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

Structured comparison

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
root table-body table-cell table-head table-header table-row table-sort-button table-state table-wrapper table-row-link
Theming hooks
table

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

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

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

resources/views/components/ui/table.blade.php Blade
@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>
resources/views/components/ui/table/header.blade.php Blade
<thead
    data-slot="table-header"
    {{ $attributes->merge(['class' => '[&_tr]:border-b [&_tr]:border-border']) }}
>
    {{ $slot }}
</thead>
resources/views/components/ui/table/body.blade.php Blade
<tbody
    data-slot="table-body"
    {{ $attributes->merge(['class' => '[&_tr:last-child]:border-0']) }}
>
    {{ $slot }}
</tbody>
resources/views/components/ui/table/row.blade.php Blade
@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>
resources/views/components/ui/table/head.blade.php Blade
@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>
resources/views/components/ui/table/sort-button.blade.php Blade
@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>
resources/views/components/ui/table/cell.blade.php Blade
@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>
resources/views/components/ui/table/state.blade.php Blade
@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>
resources/js/ui/table.js JS
/**
 * 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