Packages
pui
1.0.1
1.0.2
1.0.1
1.0.0
1.0.0-beta.18
retired
1.0.0-beta.17
retired
1.0.0-beta.16
retired
1.0.0-beta.15
retired
1.0.0-beta.14
retired
1.0.0-beta.13
retired
1.0.0-beta.12
retired
1.0.0-beta.11
retired
1.0.0-beta.10
retired
1.0.0-beta.9
retired
1.0.0-beta.8
retired
1.0.0-beta.7
retired
1.0.0-beta.6
retired
1.0.0-beta.5
retired
1.0.0-beta.4
retired
1.0.0-beta.3
retired
1.0.0-beta.1
retired
1.0.0-alpha.32
retired
1.0.0-alpha.31
retired
1.0.0-alpha.30
retired
1.0.0-alpha.29
retired
1.0.0-alpha.28
retired
1.0.0-alpha.27
retired
1.0.0-alpha.26
retired
1.0.0-alpha.25
retired
1.0.0-alpha.24
retired
1.0.0-alpha.23
retired
1.0.0-alpha.22
retired
1.0.0-alpha.21
retired
1.0.0-alpha.19
retired
1.0.0-alpha.18
retired
1.0.0-alpha.17
retired
1.0.0-alpha.16
retired
1.0.0-alpha.15
retired
1.0.0-alpha.14
retired
1.0.0-alpha.13
retired
1.0.0-alpha.12
retired
1.0.0-alpha.11
retired
A Phoenix LiveView UI toolkit
Current section
Files
Jump to
Current section
Files
guides/table.md
# PUI.Table
`PUI.Table` is a styled, stream-aware data table for Phoenix LiveView. It owns table structure and presentation while the host application owns data loading, sorting, filtering, pagination, authorization, and row actions.
## Basic usage
`rows` accepts an ordinary list. Each `:col` slot receives one row, and its `label` becomes the table header:
```heex
<.table id="users" rows={@users}>
<:col :let={user} label="Name">{user.name}</:col>
<:col :let={user} label="Email">{user.email}</:col>
<:action :let={user}>
<.button size="sm" variant="ghost" phx-click="edit" phx-value-id={user.id}>
Edit
</.button>
</:action>
</.table>
```
Rows with an `id` field receive that value as their DOM ID by default. For another identity scheme, provide `row_id`:
```heex
<.table
id="deployments"
rows={@deployments}
row_id={fn deployment -> "deployment-#{deployment.external_id}" end}
>
<:col :let={deployment} label="Environment">{deployment.environment}</:col>
<:col :let={deployment} label="Status">{deployment.status}</:col>
</.table>
```
## LiveView streams
Pass a stream directly to `rows` to use LiveView's incremental DOM updates:
```heex
<.table id="projects" rows={@streams.projects}>
<:col :let={{_dom_id, project}} label="Project">{project.name}</:col>
<:col :let={{_dom_id, project}} label="Status">{project.status}</:col>
<:action :let={{_dom_id, project}}>
<.button size="icon" variant="ghost" phx-click="delete" phx-value-id={project.id}>
<.icon name="hero-trash" class="size-4" />
</.button>
</:action>
</.table>
```
For a stream, slots receive `{dom_id, item}` tuples. This lets the host use the stream's identity and item together. `row_click` and `row_id` receive the same rendered row value:
```heex
<.table
id="invoices"
rows={@streams.invoices}
row_click={fn {_dom_id, invoice} -> JS.push("show_invoice", value: %{id: invoice.id}) end}
>
<:col :let={{_dom_id, invoice}} label="Invoice">{invoice.number}</:col>
<:col :let={{_dom_id, invoice}} label="Total">{invoice.total}</:col>
</.table>
```
## Empty state
The `:empty` slot renders for an ordinary empty list. Stream emptiness is owned by the host because a stream may be incrementally updated:
```heex
<.table id="members" rows={@members}>
<:col :let={member} label="Name">{member.name}</:col>
<:empty>
<div class="px-6 py-10 text-center text-sm text-muted-foreground">
No members have been added yet.
</div>
</:empty>
</.table>
```
## Part-level customization
The default variant supplies a complete shadcn-inspired recipe. Customize each rendered part without replacing the table markup:
```heex
<.table
id="orders"
rows={@orders}
class="rounded-xl border"
table_class="min-w-full"
header_class="bg-primary text-primary-foreground"
header_cell_class="px-4 py-3"
row_class="border-b last:border-b-0 hover:bg-accent/50"
cell_class="px-4 py-3"
action_cell_class="px-4 py-3"
>
<:col :let={order} label="Order">{order.number}</:col>
<:col :let={order} label="Customer">{order.customer_name}</:col>
<:action :let={order}>
<.button size="sm" variant="outline" phx-click="open" phx-value-id={order.id}>
Open
</.button>
</:action>
</.table>
```
Column-specific `header_class` and `cell_class` values are useful for numeric alignment or status columns:
```heex
<.table id="balances" rows={@balances}>
<:col :let={account} label="Account">{account.name}</:col>
<:col
:let={account}
label="Balance"
header_class="text-right"
cell_class="text-right tabular-nums"
>
{account.balance}
</:col>
</.table>
```
## Host-owned presentation
Every part class attribute defaults to `""`. Set all of them when the application owns every visual class but still wants PUI's table structure, row identity, stream behavior, and slot contract:
```heex
<.table
id="custom-table"
rows={@rows}
class="overflow-hidden rounded-xl border"
table_class="w-full text-sm"
header_class="bg-slate-900 text-white"
header_cell_class="px-4 py-3 text-left"
row_class="border-b border-slate-200"
cell_class="px-4 py-3"
>
<:col :let={row} label="Name">{row.name}</:col>
</.table>
```
Overriding the part classes leaves the semantic table elements, stable body ID, stream metadata, slots, and row behavior intact. A table has no hook-managed behavior, so write a plain `<table>` when PUI's structure is not wanted at all.
## Responsibility boundary
PUI.Table deliberately does not implement:
- sorting or sort indicators;
- filtering or search inputs;
- pagination or infinite loading;
- authorization decisions;
- database queries or stream lifecycle;
- row action semantics.
Compose those behaviors in the host LiveView and pass the resulting rows, callbacks, and controls into the table.
## API reference
### Attributes
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `id` | `string` | required | Stable ID for the table body and LiveView stream target |
| `rows` | `list` or stream | required | Rows to render |
| `row_id` | function | derived | Returns a DOM ID for a rendered row |
| `row_click` | function | `nil` | Returns the `phx-click` value for data cells |
| `row_item` | function | identity | Maps a rendered row before passing it to slots |
| `action_label` | `string` | `"Actions"` | Accessible label for the action column |
| `class` | `string` | `""` | Outer overflow wrapper classes |
| `caption_class` | `string` | `"sr-only"` | Table caption classes |
| `table_class` | `string` | `""` | `<table>` classes |
| `header_class` | `string` | `""` | `<thead>` classes |
| `header_cell_class` | `string` | `""` | Default classes for header cells |
| `body_class` | `string` | `""` | `<tbody>` classes |
| `row_class` | `string` | `""` | Default classes for rows |
| `cell_class` | `string` | `""` | Default classes for data cells |
| `action_header_class` | `string` | `""` | Action header cell classes |
| `action_cell_class` | `string` | `""` | Action cell classes |
| `action_class` | `string` | `""` | Inner action wrapper classes |
| `empty_class` | `string` | `""` | Empty-state cell classes |
Global HTML and ARIA attributes are forwarded to the `<table>` element.
### Slots
| Slot | Required | Attributes | Description |
|------|----------|------------|-------------|
| `caption` | — | — | Optional accessible table caption |
| `col` | yes | `label`, `class`, `header_class`, `cell_class` | A data column rendered for every row |
| `action` | — | `class` | Row actions rendered in a final column |
| `empty` | — | — | Empty state for ordinary empty lists |