PUI.Table

Copy Markdown

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:

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

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

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

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

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

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

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

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

NameTypeDefaultDescription
idstringrequiredStable ID for the table body and LiveView stream target
rowslist or streamrequiredRows to render
row_idfunctionderivedReturns a DOM ID for a rendered row
row_clickfunctionnilReturns the phx-click value for data cells
row_itemfunctionidentityMaps a rendered row before passing it to slots
action_labelstring"Actions"Accessible label for the action column
classstring""Outer overflow wrapper classes
caption_classstring"sr-only"Table caption classes
table_classstring""<table> classes
header_classstring""<thead> classes
header_cell_classstring""Default classes for header cells
body_classstring""<tbody> classes
row_classstring""Default classes for rows
cell_classstring""Default classes for data cells
action_header_classstring""Action header cell classes
action_cell_classstring""Action cell classes
action_classstring""Inner action wrapper classes
empty_classstring""Empty-state cell classes

Global HTML and ARIA attributes are forwarded to the <table> element.

Slots

SlotRequiredAttributesDescription
captionOptional accessible table caption
colyeslabel, class, header_class, cell_classA data column rendered for every row
actionclassRow actions rendered in a final column
emptyEmpty state for ordinary empty lists