Fluxon.Components.Table
(Fluxon v3.0.0)
A compound table system for displaying structured data with custom cell content and styling.
Table is a pure-markup component family with no client-side state. It composes a semantic
<table> element from a small set of slot-driven helpers that map directly onto native
table elements (<thead>, <tbody>, <tr>, <th>, <td>), while applying consistent
borders, spacing, and typography from your design tokens. Because each helper renders a
real table element rather than styled <div>s, the native table semantics and structure
of the markup are preserved.
The system is composed of four pieces designed to be used together:
table/1: the outer<table>container that sets minimum width and base typography.table_head/1: a<thead>section whose columns are declared through the:colslot.table_body/1: a<tbody>wrapper for one or more rows.table_row/1: a<tr>whose cells are declared through the:cellslot.
A typical structure looks like this:
table
├── table_head
│ └── :col slots (rendered as <th>)
└── table_body
└── table_row
└── :cell slots (rendered as <td>)Slot attributes pass through to cells
Both :col and :cell declare validate_attrs: false, which means any HTML or
Phoenix attribute placed on the slot is forwarded to the rendered <th> or <td>.
This is how you attach phx-click to a header for sortable columns, set per-cell
colspan and rowspan, override class on a single cell, or wire up data-*
attributes used by row-level interactions.
Usage
Render a basic data table with headers and rows:
<.table>
<.table_head>
<:col>Name</:col>
<:col>Status</:col>
<:col>Email</:col>
</.table_head>
<.table_body>
<.table_row>
<:cell>Alice Smith</:cell>
<:cell>New</:cell>
<:cell>alice@example.com</:cell>
</.table_row>
<.table_row>
<:cell>Bob Johnson</:cell>
<:cell>In Progress</:cell>
<:cell>bob@example.com</:cell>
</.table_row>
</.table_body>
</.table>Each :col becomes a <th> with consistent padding and a muted label color. Each
:cell becomes a <td> with matching horizontal padding so columns line up vertically.
The first and last cells of every row receive an extra inset on sm and larger
viewports so the column edges align with surrounding content.
Rendering Rows from a List
In practice, rows are almost always generated from a server-side collection with :for:
<.table>
<.table_head>
<:col>Customer</:col>
<:col>Plan</:col>
<:col>Status</:col>
</.table_head>
<.table_body>
<.table_row :for={customer <- @customers}>
<:cell>{customer.name}</:cell>
<:cell>{customer.plan}</:cell>
<:cell>
<.badge color={customer.status_color}>{customer.status}</.badge>
</:cell>
</.table_row>
</.table_body>
</.table>Wrap the rendering in a Phoenix.Component.form/1, a streamed assign, or any other
source as needed. The component does not impose any specific data shape.
Rich Cell Content
Cells accept arbitrary content, not just text. Combine avatars, badges, icons, and
multi-line layouts inside a single :cell to build dense, scannable rows:
<.table>
<.table_head>
<:col>Lead</:col>
<:col>Stage</:col>
<:col>Contact</:col>
<:col></:col>
</.table_head>
<.table_body>
<.table_row>
<:cell class="w-full flex items-center gap-2">
<img src="https://i.pravatar.cc/150?u=1" class="size-9 rounded-full" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold">Sarah Johnson</span>
<span class="text-zinc-400 text-sm/3">Product Manager</span>
</div>
</:cell>
<:cell>
<.badge color="green">Active</.badge>
</:cell>
<:cell>sarah.j@example.com</:cell>
<:cell>
<.icon name="hero-ellipsis-horizontal" class="size-5" />
</:cell>
</.table_row>
</.table_body>
</.table>Cells are styled with whitespace-nowrap by default so single-line content does not
wrap unexpectedly. Override this by passing whitespace-normal (or any other Tailwind
utility) through the cell's class attribute when wrapping is desired.
Per-Column and Per-Cell Overrides
Both :col and :cell accept a class attribute that is merged with the component's
base styles. Use this to control alignment, width, or visibility per column without
reaching for global table CSS:
<.table>
<.table_head>
<:col class="w-12">#</:col>
<:col>Description</:col>
<:col class="text-right">Amount</:col>
</.table_head>
<.table_body>
<.table_row :for={{item, index} <- Enum.with_index(@items, 1)}>
<:cell class="text-foreground-softest">{index}</:cell>
<:cell>{item.description}</:cell>
<:cell class="text-right font-mono">{format_money(item.amount)}</:cell>
</.table_row>
</.table_body>
</.table>Because both slots forward arbitrary attributes, native HTML attributes such as
colspan, rowspan, scope, and headers are also accepted on the slot directly.
Sortable Columns
Headers can trigger LiveView events by attaching phx-click (and any related
phx-value-*) directly to a :col slot. Because the column slot forwards arbitrary
attributes, sorting is implemented entirely on the server with no extra wiring:
<.table>
<.table_head>
<:col phx-click="sort" phx-value-column="name" class="cursor-pointer">
<div class="flex items-center gap-1">
Lead <.icon name="hero-chevron-up-down" class="size-4 text-zinc-500" />
</div>
</:col>
<:col phx-click="sort" phx-value-column="stage" class="cursor-pointer">
<div class="flex items-center gap-1">
Stage <.icon name="hero-chevron-up-down" class="size-4 text-zinc-500" />
</div>
</:col>
<:col>Contact</:col>
</.table_head>
<.table_body>
<.table_row :for={lead <- @leads}>
<:cell>{lead.name}</:cell>
<:cell>{lead.stage}</:cell>
<:cell>{lead.email}</:cell>
</.table_row>
</.table_body>
</.table>Handle the event on the LiveView and re-render the table with the new ordering:
def handle_event("sort", %{"column" => column}, socket) do
{:noreply, assign(socket, :leads, sort_leads(socket.assigns.leads, column))}
endUse a different chevron icon (hero-chevron-up, hero-chevron-down) on the active
column to indicate the current direction. Drive the icon choice from a server assign
so it stays in sync with the actual ordering.
Row Selection
Place a Fluxon.Components.Checkbox in the first column of both the header and each
row to build a multi-select pattern. The header checkbox can drive a "select all"
action while per-row checkboxes feed back into a form input array:
<.table>
<.table_head>
<:col class="w-8"><.checkbox name="select-all" phx-click="toggle_all" /></:col>
<:col>Lead</:col>
<:col>Stage</:col>
<:col>Contact</:col>
</.table_head>
<.table_body>
<.table_row :for={lead <- @leads}>
<:cell>
<.checkbox name="selected_leads[]" value={lead.id} checked={lead.id in @selected} />
</:cell>
<:cell>{lead.name}</:cell>
<:cell>{lead.stage}</:cell>
<:cell>{lead.email}</:cell>
</.table_row>
</.table_body>
</.table>Wrap the table in a <.form> element when the selected ids should be submitted along
with a bulk action button.
Clickable Rows
Rows accept any phx-* attribute through :rest, which makes it straightforward to
navigate or trigger an event when a row is clicked. Combine this with cursor-pointer
and a hover background utility to communicate that the row is interactive:
<.table>
<.table_head>
<:col>Customer</:col>
<:col>Status</:col>
<:col>Last Order</:col>
</.table_head>
<.table_body>
<.table_row
:for={customer <- @customers}
class="cursor-pointer hover:highlight"
phx-click="show_customer"
phx-value-id={customer.id}
>
<:cell>
<div class="flex items-center gap-2">
<img src={customer.avatar} class="size-8 rounded-full" />
<span class="font-medium">{customer.name}</span>
</div>
</:cell>
<:cell><.badge color="green">Active</.badge></:cell>
<:cell>{customer.last_order_date}</:cell>
</.table_row>
</.table_body>
</.table>For direct client-side navigation without a server round trip, use
Phoenix.LiveView.JS.navigate/2 instead. Adding tabindex="0" and a focus style
exposes the row to keyboard navigation:
<.table_row
:for={project <- @projects}
tabindex="0"
class={[
"group cursor-pointer",
"hover:highlight",
"focus:outline-hidden focus-visible:bg-zinc-50"
]}
phx-click={JS.navigate(~p"/projects/#{project.id}")}
>
<:cell>
<div class="flex items-center justify-between">
<span class="font-medium">{project.name}</span>
<.icon
name="hero-arrow-right"
class="size-4 text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</:cell>
<:cell><.badge color={project.status_color}>{project.status}</.badge></:cell>
</.table_row>Avoid nested interactive controls inside clickable rows
Putting a <button>, <a>, or other interactive element inside a row that already
has phx-click produces nested click targets and ambiguous keyboard semantics. When
a row needs both a primary navigation action and inline controls (for example a
delete button), keep the row non-interactive and place the primary action on a
dedicated cell, or stop event propagation on the inner control.
Empty States
The component does not render an empty state automatically. Render a single full-width row when the data source is empty so the table keeps its structure:
<.table>
<.table_head>
<:col>Name</:col>
<:col>Status</:col>
<:col>Email</:col>
</.table_head>
<.table_body>
<.table_row :if={@customers == []}>
<:cell colspan="3" class="text-center text-foreground-softer py-8">
No customers yet.
</:cell>
</.table_row>
<.table_row :for={customer <- @customers}>
<:cell>{customer.name}</:cell>
<:cell>{customer.status}</:cell>
<:cell>{customer.email}</:cell>
</.table_row>
</.table_body>
</.table>Examples
Invoice line items with right-aligned amounts and a summary row:
<.table>
<.table_head>
<:col>Item</:col>
<:col class="text-right w-24">Qty</:col>
<:col class="text-right w-32">Unit Price</:col>
<:col class="text-right w-32">Total</:col>
</.table_head>
<.table_body>
<.table_row :for={line <- @invoice.lines}>
<:cell>
<div class="font-medium">{line.name}</div>
<div class="text-sm text-foreground-softer">{line.description}</div>
</:cell>
<:cell class="text-right">{line.quantity}</:cell>
<:cell class="text-right font-mono">{format_money(line.unit_price)}</:cell>
<:cell class="text-right font-mono">{format_money(line.total)}</:cell>
</.table_row>
<.table_row class="font-semibold">
<:cell colspan="3" class="text-right">Total due</:cell>
<:cell class="text-right font-mono">{format_money(@invoice.total)}</:cell>
</.table_row>
</.table_body>
</.table>Streamed user list with avatars, role badges, and a row-level menu:
<.table>
<.table_head>
<:col>User</:col>
<:col>Role</:col>
<:col>Joined</:col>
<:col class="w-12"></:col>
</.table_head>
<.table_body id="users" phx-update="stream">
<.table_row :for={{dom_id, user} <- @streams.users} id={dom_id}>
<:cell>
<div class="flex items-center gap-3">
<img src={user.avatar_url} class="size-9 rounded-full" />
<div class="flex flex-col">
<span class="font-medium">{user.name}</span>
<span class="text-foreground-softer text-sm">{user.email}</span>
</div>
</div>
</:cell>
<:cell>
<.badge color={role_color(user.role)}>{user.role}</.badge>
</:cell>
<:cell>{Calendar.strftime(user.inserted_at, "%b %d, %Y")}</:cell>
<:cell>
<.dropdown>
<:trigger>
<.button variant="ghost" size="sm" aria-label="User actions">
<.icon name="hero-ellipsis-horizontal" class="size-4" />
</.button>
</:trigger>
<.dropdown_item phx-click="edit_user" phx-value-id={user.id}>
Edit
</.dropdown_item>
<.dropdown_item phx-click="remove_user" phx-value-id={user.id} data-confirm="Remove this user?">
Remove
</.dropdown_item>
</.dropdown>
</:cell>
</.table_row>
</.table_body>
</.table>Sortable customer table with a server-driven sort indicator:
<.table>
<.table_head>
<:col phx-click="sort" phx-value-column="name" class="cursor-pointer">
<div class="flex items-center gap-1">
Customer
<.icon name={sort_icon(@sort, :name)} class="size-4 text-zinc-500" />
</div>
</:col>
<:col phx-click="sort" phx-value-column="created_at" class="cursor-pointer">
<div class="flex items-center gap-1">
Created
<.icon name={sort_icon(@sort, :created_at)} class="size-4 text-zinc-500" />
</div>
</:col>
<:col>Plan</:col>
</.table_head>
<.table_body>
<.table_row :for={customer <- @customers}>
<:cell>{customer.name}</:cell>
<:cell>{Calendar.strftime(customer.inserted_at, "%b %d, %Y")}</:cell>
<:cell>{customer.plan}</:cell>
</.table_row>
</.table_body>
</.table>Bulk-action table with row selection wrapped in a form:
<.form for={%{}} phx-submit="archive_selected">
<.table>
<.table_head>
<:col class="w-8">
<.checkbox name="select-all" phx-click={JS.dispatch("toggle-all", to: "#users")} />
</:col>
<:col>User</:col>
<:col>Status</:col>
</.table_head>
<.table_body id="users">
<.table_row :for={user <- @users}>
<:cell>
<.checkbox name="user_ids[]" value={user.id} />
</:cell>
<:cell>{user.name}</:cell>
<:cell>{user.status}</:cell>
</.table_row>
</.table_body>
</.table>
<.button type="submit" class="mt-4">Archive selected</.button>
</.form>
Summary
Components
Renders the outer <table> container with base typography and spacing.
Renders a <tbody> wrapper that contains the table's rows.
Renders a <thead> section with one <th> per :col slot.
Renders a <tr> with one <td> per :cell slot.
Components
Renders the outer <table> container with base typography and spacing.
Use this as the wrapper for any data table. The container sets a minimum width so the table behaves predictably inside flex and grid parents, applies the design system's text size and foreground color, and aligns text to the start. It does not impose any borders, striping, or hover effects of its own; those concerns are handled by the child components.
Place a table_head/1 and a table_body/1 inside this component to declare columns
and rows. Any HTML attribute (including id, aria-*, and phx-*) is forwarded to
the underlying <table> element through the global :rest attribute.
Attributes
class(:any) - Additional CSS classes merged with the table's base typography and width styles. Use this to override the minimum width, swap text colors, or add a wrapper border by composing utilities such asmin-w-0,text-base, orborderon the element.Defaults to
nil.Global attributes are accepted. Arbitrary HTML attributes forwarded to the underlying
<table>element.
Slots
inner_block(required) - The body of the table. Should contain atable_head/1for column declarations and atable_body/1for row content. Other table-level elements such as<caption>or<colgroup>may also be placed here when needed.
Renders a <tbody> wrapper that contains the table's rows.
Use this inside a table/1 to group one or more table_row/1 components. The
component does not apply any styling of its own and exists primarily to preserve
semantic table structure and to give callers a stable place to attach LiveView
features such as id, phx-update="stream", and phx-update="ignore".
Basic Usage
<.table_body>
<.table_row>
<:cell>John Smith</:cell>
<:cell>john@example.com</:cell>
<:cell>Active</:cell>
</.table_row>
</.table_body>Streaming Rows
<.table_body id="users" phx-update="stream">
<.table_row :for={{dom_id, user} <- @streams.users} id={dom_id}>
<:cell>{user.name}</:cell>
<:cell>{user.email}</:cell>
</.table_row>
</.table_body>Attributes
class(:any) - Additional CSS classes for the underlying<tbody>element. Useful for applying striping with[&>tr:nth-child(even)]:bg-zinc-50or restricting hover effects to body rows only.Defaults to
nil.Global attributes are accepted. Arbitrary HTML attributes forwarded to the underlying
<tbody>element. Setidandphx-update="stream"here when rendering streamed rows.
Slots
inner_block(required) - The body content. Typically a series oftable_row/1entries rendered with:for.
Renders a <thead> section with one <th> per :col slot.
Use this inside a table/1 to declare the columns of a table. Each entry in the
:col slot becomes a header cell with consistent padding, a muted label color, and
left-aligned text. Because :col declares validate_attrs: false, any attribute
placed on the slot is forwarded to the rendered <th>, which is the recommended way
to attach phx-click for sortable columns or to set per-column class overrides for
width and alignment.
Basic Usage
<.table_head>
<:col>Name</:col>
<:col>Email</:col>
<:col>Status</:col>
</.table_head>Column Width and Alignment
<.table_head>
<:col class="w-12">#</:col>
<:col>Description</:col>
<:col class="text-right w-32">Amount</:col>
</.table_head>Sortable Columns
<.table_head>
<:col phx-click="sort" phx-value-column="name" class="cursor-pointer">
<div class="flex items-center gap-1">
Name <.icon name="hero-chevron-up-down" class="size-4" />
</div>
</:col>
</.table_head>Attributes
class(:any) - Additional CSS classes merged with the header's base muted text color and bottom border. Use this to swap the border color, change the header background, or apply sticky positioning with utilities likesticky top-0 bg-background.Defaults to
nil.Global attributes are accepted. Arbitrary HTML attributes forwarded to the underlying
<thead>element.
Slots
col(required) - Declares a column header. Each slot entry renders as a<th>with consistent padding and label styling. The slot does not validate its attributes, so any HTML attribute (class,colspan,scope) or Phoenix binding (phx-click,phx-value-*) placed on the slot is forwarded to the rendered cell. Passing aclasshere overrides width and alignment for that column.
Renders a <tr> with one <td> per :cell slot.
Use this inside a table_body/1 to render a single row of data. Each entry in the
:cell slot becomes a body cell with consistent padding, vertical rhythm, and
whitespace-nowrap so single-line content does not wrap. Rows render with a bottom
border that is automatically removed on the last row of the body, keeping the table
visually balanced without manual class overrides.
Because :cell declares validate_attrs: false, any attribute placed on the slot
(including class, colspan, rowspan, and data-*) is forwarded to the rendered
<td>. The row itself accepts any phx-* binding through :rest, which is the
recommended way to make a row clickable or navigable.
Basic Usage
<.table_row>
<:cell>John Smith</:cell>
<:cell>john@example.com</:cell>
<:cell>Active</:cell>
</.table_row>Rich Cell Content
<.table_row>
<:cell class="w-full flex items-center gap-2">
<img src={@user.avatar} class="size-9 rounded-full" />
<div class="flex flex-col">
<span class="font-semibold">{@user.name}</span>
<span class="text-zinc-400 text-sm">{@user.role}</span>
</div>
</:cell>
<:cell><.badge color="green">Active</.badge></:cell>
</.table_row>Clickable Row
<.table_row
class="cursor-pointer hover:highlight"
phx-click="show_customer"
phx-value-id={@customer.id}
>
<:cell>{@customer.name}</:cell>
<:cell>{@customer.email}</:cell>
</.table_row>Spanning Cells
<.table_row>
<:cell colspan="3" class="text-center text-foreground-softer py-8">
No results found.
</:cell>
</.table_row>Attributes
class(:any) - Additional CSS classes merged with the row's base bottom-border style. Use this to addcursor-pointerand a hover background for clickable rows, to apply a different border color, or to highlight rows based on row state withdata-*variants.Defaults to
nil.Global attributes are accepted. Arbitrary HTML attributes forwarded to the underlying
<tr>element. Includes anyphx-*binding (phx-click,phx-value-*), standard HTML attributes (id,tabindex), anddata-*attributes used to drive row-level styling.
Slots
cell(required) - Declares a body cell. Each slot entry renders as a<td>with consistent padding andwhitespace-nowrap. The slot does not validate its attributes, so any HTML attribute (class,colspan,rowspan,headers) ordata-*binding placed on the slot is forwarded to the rendered cell. Passclass="whitespace-normal"to allow content to wrap, orclass="text-right"to align numeric columns.