Data Display Table

Table

A styled, stream-aware data table with explicit customization points for Phoenix LiveView.

PUI.Table provides the structure and presentation for collection views while the host LiveView owns data loading, sorting, filtering, pagination, authorization, and row actions.

Import

use PUI
# or
import PUI.Table

Basic table

Use the :col slot for labeled columns and the optional :action slot for row actions:

<.table id="projects" rows={@projects}>
<:col :let={project} label="Project">{project.name}</:col>
<:col :let={project} label="Status">{project.status}</:col>
<:action :let={project}>
<.button size="sm" variant="ghost" phx-click="open" phx-value-id={project.id}>
Open
</.button>
</:action>
</.table>

Styled table with row actions

Project Owner Status Actions
PUI Design Systems Published
Keping Accounting In review
Bundar Infrastructure Draft

Unstyled table with part classes

Project Status
PUI Published
Keping In review
Bundar Draft

Empty list

Project
No projects found.

Rows with an id field receive that value as their DOM ID. Supply row_id when the host uses a different identity:

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

Tables accept @streams directly. Stream rows are passed to slots as {dom_id, item} tuples, and the table marks its body with phx-update="stream":

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

The table does not call Enum on a stream before rendering it. Keep stream operations in the LiveView and use row_item only when the slot needs a different shape:

<.table
id="events"
rows={@streams.events}
row_item={fn {_dom_id, event} -> event end}
>
<:col :let={event} label="Event">{event.name}</:col>
</.table>

Row interactions

row_click receives the same rendered row value as row_id. It may return a LiveView JS command or another valid phx-click value:

<.table
id="invoices"
rows={@invoices}
row_click={fn invoice -> JS.push("show_invoice", value: %{id: invoice.id}) end}
>
<:col :let={invoice} label="Invoice">{invoice.number}</:col>
<:col :let={invoice} label="Total">{invoice.total}</:col>
</.table>

Only data cells receive the row click. Action cells remain independent so an edit or delete button does not accidentally trigger the row action.

Empty state

The :empty slot renders when an ordinary list is empty:

<.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 found.
</div>
</:empty>
</.table>

For streams, the host should render an empty state outside the table when its domain state knows that no records are available. A stream is intentionally not consumed early just to infer emptiness.

Part-level customization

The default variant supplies PUI's semantic shadcn-inspired recipe. Each major part has its own class attribute:

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

Columns can override the defaults independently:

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

Unstyled mode

Use variant="unstyled" when the host design system owns every visual class:

<.table
id="custom-table"
rows={@rows}
variant="unstyled"
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>

Unstyled mode preserves table semantics, stream metadata, stable IDs, slots, and callbacks while removing PUI's visual defaults.

Responsibility boundary

PUI.Table deliberately does not implement sorting, filtering, pagination, database queries, authorization, or stream lifecycle. Those concerns stay in the host LiveView so the same table can render a small list, a paginated query, or a LiveView stream without taking ownership of application behavior.

API reference

Attributes

Name Type Default Description
id string required Stable ID for the table body and stream target
rows list or stream required Rows to render
row_id function derived Returns the DOM ID for a rendered row
row_click function nil Returns the phx-click value for data cells
row_item function identity Maps rows before passing them to slots
variant string "default" "default" or "unstyled"
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 header cell classes
body_class string "" <tbody> classes
row_class string "" Default row classes
cell_class string "" Default data cell classes
action_header_class string "" Action header cell classes
action_cell_class string "" Action data cell classes
action_class string "" Inner action wrapper classes
empty_class string "" Empty-state cell classes

Slots

Slot Required Attributes Description
caption Optional table caption
col yes label, class, header_class, cell_class A data column
action class Row actions in the final column
empty Empty state for ordinary lists