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 :col slot.
  • table_body/1: a <tbody> wrapper for one or more rows.
  • table_row/1: a <tr> whose cells are declared through the :cell slot.

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))}
end

Use 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

table(assigns)

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 as min-w-0, text-base, or border on 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 a table_head/1 for column declarations and a table_body/1 for row content. Other table-level elements such as <caption> or <colgroup> may also be placed here when needed.

table_body(assigns)

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-50 or restricting hover effects to body rows only.

    Defaults to nil.

  • Global attributes are accepted. Arbitrary HTML attributes forwarded to the underlying <tbody> element. Set id and phx-update="stream" here when rendering streamed rows.

Slots

  • inner_block (required) - The body content. Typically a series of table_row/1 entries rendered with :for.

table_head(assigns)

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 like sticky 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 a class here overrides width and alignment for that column.

table_row(assigns)

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 add cursor-pointer and a hover background for clickable rows, to apply a different border color, or to highlight rows based on row state with data-* variants.

    Defaults to nil.

  • Global attributes are accepted. Arbitrary HTML attributes forwarded to the underlying <tr> element. Includes any phx-* binding (phx-click, phx-value-*), standard HTML attributes (id, tabindex), and data-* attributes used to drive row-level styling.

Slots

  • cell (required) - Declares a body cell. Each slot entry renders as a <td> with consistent padding and whitespace-nowrap. The slot does not validate its attributes, so any HTML attribute (class, colspan, rowspan, headers) or data-* binding placed on the slot is forwarded to the rendered cell. Pass class="whitespace-normal" to allow content to wrap, or class="text-right" to align numeric columns.