Components Table

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

Table

Sortable headers, density, stripes - the presentation layer for data. The component renders state and fires events; your app owns the reorder.
The presentation layer for data

Rows in, markup out - and honest sorting: a sortable header is a real button that fires on_sort (default event "sort") with the column's sort_key, aria-sort announces the state, and your app owns the actual reorder. The :footer slot pins a totals row (colspan works); :empty_state renders whenever rows is empty. density="compact", striped, sticky_header and the frameless variant="ghost" restyle it; LiveView streams work unchanged.

Role Status
Sarah Chen Engineering 34 Active
Alex Rivera Design 29 Active
Jordan Lee Support 41 Away
3 people avg 35
heex
<div class="max-w-full overflow-x-auto">
<.table
sort_by="age"
sort_dir="asc"
rows={[
%{name: "Sarah Chen", role: "Engineering", age: 34, status: "Active"},
%{name: "Alex Rivera", role: "Design", age: 29, status: "Active"},
%{name: "Jordan Lee", role: "Support", age: 41, status: "Away"}
]}
>
<:col :let={p} label="Name" sortable sort_key="name">{p.name}</:col>
<:col :let={p} label="Role">{p.role}</:col>
<:col :let={p} label="Age" sortable sort_key="age">{p.age}</:col>
<:col :let={p} label="Status">
<.badge
color={if p.status == "Active", do: "success", else: "gray"}
variant="soft"
size="sm"
label={p.status}
/>
</:col>
<:footer>
<.td colspan={2}>3 people</.td>
<.td>avg 35</.td>
<.td></.td>
</:footer>
</.table>
</div>
People cells

user_inner_td is the avatar + name + sub-label cell in one helper - avatar_assigns passes straight through to the avatar, so name-hashed initials work with no photos anywhere.

User Plan
Alex Rivera
alex@example.com
Team
Ada Lovelace
ada@analytical.engine
Pro
heex
<div class="max-w-full overflow-x-auto">
<.table rows={[
%{name: "Alex Rivera", email: "alex@example.com", plan: "Team"},
%{name: "Ada Lovelace", email: "ada@analytical.engine", plan: "Pro"}
]}>
<:col :let={u} label="User">
<.user_inner_td
label={u.name}
sub_label={u.email}
avatar_assigns={%{name: u.name, size: "sm"}}
/>
</:col>
<:col :let={u} label="Plan">{u.plan}</:col>
</.table>
</div>
Properties
<.table>
Attribute Type Default Description
class any nil CSS class
density string "comfortable" row height. compact halves the vertical padding for data-heavy screens
one of: "comfortable", "compact"
id string
on_sort any "sort" event name (or JS command) fired when a sortable header is clicked; receives phx-value-sort of the column's sort_key. May also be a 1-arity function of the sort_key returning the event/JS per column - how the data table patches per-column sort URLs in link mode.
rest global
row_click any nil the function for handling phx-click on each row
row_id any nil the function for generating the row id
row_item any &Function.identity/1 the function for mapping each row before calling the :col slot
rows list [] the list of rows to render
sort_by any nil the currently sorted column's sort_key
sort_dir string "asc" the current sort direction
one of: "asc", "desc"
sticky_header boolean false keep the header row pinned while the table scrolls (the table needs a scrollable ancestor)
striped boolean false wash alternate rows for easier scanning
variant string "basic"
one of: "ghost", "basic"
:col slot slot
:empty_state slot slot A message to show when the table is empty, to be used together with :col
:footer slot slot a totals/summary row pinned under the body - supply <.td> cells (colspan works), e.g. <.td colspan={3}>Total</.td><.td>$2,500</.td>
<.user_inner_td>
Attribute Type Default Description
avatar_assigns map nil if using an avatar, this map will be passed to the avatar component as props
class any nil CSS class
label string nil Adds a label your user, e.g name
rest global
sub_label string nil Adds a sub-label your to your user, e.g title