Table

Structured data table with header, body, row, and cell slots.

Basic

Name Email Role
Sarah Johnson sarah@example.com Admin
Michael Chen michael@example.com Developer
Emily Davis emily@example.com Designer
.heex
<.table>
  <.table_head>
    <:col>Name</:col>
    <:col>Email</:col>
    <:col>Role</:col>
  </.table_head>
  <.table_body>
    <.table_row :for={user <- @users}>
      <:cell>{user.name}</:cell>
      <:cell>{user.email}</:cell>
      <:cell>{user.role}</:cell>
    </.table_row>
  </.table_body>
</.table>

Column layout

Set column widths with <colgroup> and align cells via class on each :cell. Numeric columns read better right-aligned with font-mono.

# Description Qty Amount
1 Annual subscription 1 $1,200.00
2 Premium support 1 $240.00
3 Additional seats 5 $450.00
.heex
<.table>
  <colgroup>
    <col class="w-12" />
    <col />
    <col class="w-24" />
    <col class="w-32" />
  </colgroup>
  <.table_head>
    <:col>#</:col>
    <:col>Description</:col>
    <:col class="text-right">Qty</:col>
    <:col class="text-right">Amount</:col>
  </.table_head>
  <.table_body>
    <.table_row :for={{item, idx} <- Enum.with_index(@items, 1)}>
      <:cell class="text-foreground-softest">{idx}</:cell>
      <:cell>{item.name}</:cell>
      <:cell class="text-right font-mono">{item.qty}</:cell>
      <:cell class="text-right font-mono">{item.amount}</:cell>
    </.table_row>
  </.table_body>
</.table>

Rich content

Cells accept any markup. Compose avatars, badges, and stacked text inside a single :cell.

User Department Last login Status
Sarah Johnson Product Manager
Engineering 2 hours ago Active
Michael Chen Senior Developer
Engineering 1 day ago Active
Emily Davis UX Designer
Design 3 days ago Away
.heex
<.table>
  <.table_head>
    <:col>User</:col>
    <:col>Department</:col>
    <:col>Last login</:col>
    <:col>Status</:col>
  </.table_head>
  <.table_body>
    <.table_row :for={user <- @users}>
      <:cell class="w-full">
        <div class="flex items-center gap-3">
          <img src={user.avatar} class="size-9 rounded-full" />
          <div class="flex flex-col gap-0.5">
            <span class="font-semibold">{user.name}</span>
            <span class="text-foreground-softer text-sm">{user.role}</span>
          </div>
        </div>
      </:cell>
      <:cell>{user.department}</:cell>
      <:cell class="text-foreground-softer">{user.last_login}</:cell>
      <:cell>
        <.badge color={if user.status == "Active", do: "success", else: "warning"}>
          {user.status}
        </.badge>
      </:cell>
    </.table_row>
  </.table_body>
</.table>

Row actions

Project Owner Updated
Pricing redesign Sarah Johnson 2 hours ago
Mobile onboarding Michael Chen Yesterday
Quarterly report Emily Davis 3 days ago
.heex
<.table>
  <.table_head>
    <:col>Project</:col>
    <:col>Owner</:col>
    <:col>Updated</:col>
    <:col class="w-12 text-right"></:col>
  </.table_head>
  <.table_body>
    <.table_row :for={project <- @projects}>
      <:cell class="font-medium">{project.name}</:cell>
      <:cell>{project.owner}</:cell>
      <:cell class="text-foreground-softer">{project.updated}</:cell>
      <:cell class="text-right">
        <.dropdown placement="bottom-end">
          <:toggle class="size-8 justify-center rounded-base hover:highlight">
            <.icon name="hero-ellipsis-horizontal" class="icon" />
            <span class="sr-only">Project actions</span>
          </:toggle>

          <.dropdown_button>
            <.icon name="hero-pencil" class="icon" /> Edit
          </.dropdown_button>
          <.dropdown_button>
            <.icon name="hero-eye" class="icon" /> View
          </.dropdown_button>
          <.dropdown_button class="text-danger data-highlighted:bg-danger-soft/50">
            <.icon name="hero-trash" class="icon" /> Delete
          </.dropdown_button>
        </.dropdown>
      </:cell>
    </.table_row>
  </.table_body>
</.table>

Selectable rows

Pure CSS row state. Drop a checkbox in the first cell and apply has-checked:highlight-strong on the row to tint the selection without any LiveView wiring.

Name Email Role
Alice Johnson alice@example.com Admin
Bob Smith bob@example.com Developer
Charlie Brown charlie@example.com Manager
Diana Lee diana@example.com Designer
.heex
<.table>
  <colgroup>
    <col class="w-12" />
    <col />
    <col />
    <col />
  </colgroup>
  <.table_head>
    <:col class="pl-4!">
      <div class="flex items-center">
        <.checkbox
          name="select-all"
          onclick="this.closest('table').querySelectorAll('tbody input[type=checkbox]').forEach(c => c.checked = this.checked)"
        />
      </div>
    </:col>
    <:col>Name</:col>
    <:col>Email</:col>
    <:col>Role</:col>
  </.table_head>
  <.table_body>
    <.table_row
      :for={user <- @users}
      class="group has-checked:highlight-strong hover:highlight"
    >
      <:cell class="relative pl-4!">
        <span class="absolute inset-y-0 left-0 hidden w-[2px] bg-primary group-has-checked:block"></span>
        <div class="flex items-center">
          <.checkbox name={"row-#{user.id}"} />
        </div>
      </:cell>
      <:cell class="font-medium">{user.name}</:cell>
      <:cell>{user.email}</:cell>
      <:cell>{user.role}</:cell>
    </.table_row>
  </.table_body>
</.table>

Clickable rows

Bind phx-click on table_row for whole-row interactions. Pair with cursor-pointer and a hover tint so the affordance is obvious.

Lead Stage Contact
Alice Smith New alice.smith@example.com
Bob Johnson In Progress bob.johnson@example.com
Carol Williams Completed carol.williams@example.com
David Brown Urgent david.brown@example.com
.heex
<.table>
  <.table_head>
    <:col>Lead</:col>
    <:col>Stage</:col>
    <:col>Contact</:col>
  </.table_head>
  <.table_body>
    <.table_row
      :for={lead <- @leads}
      id={"lead-#{lead.id}"}
      phx-click={
        Fluxon.open_dialog("lead-details-modal")
        |> JS.push("load-lead", value: %{id: lead.id})
      }
      class="cursor-pointer hover:highlight"
    >
      <:cell class="font-medium">{lead.name}</:cell>
      <:cell>{lead.stage}</:cell>
      <:cell>{lead.contact}</:cell>
    </.table_row>
  </.table_body>
</.table>

<.modal id="lead-details-modal" class="w-[480px]" on_close={JS.push("reset-lead")}>
  <div :if={!@lead} class="animate-pulse space-y-4">
    <%# Skeleton placeholders here while data is loading %>
  </div>

  <div :if={@lead} class="space-y-5 animate-in fade-in duration-200">
    <%# Lead details rendered once @lead is populated %>
  </div>
</.modal>

# In the LiveView:
def handle_event("load-lead", %{"id" => id}, socket) do
  Process.send_after(self(), {:lead_loaded, id}, 800)
  {:noreply, assign(socket, lead: nil)}
end

def handle_event("reset-lead", _, socket) do
  {:noreply, assign(socket, lead: nil)}
end

def handle_info({:lead_loaded, id}, socket) do
  {:noreply, assign(socket, lead: Leads.get!(id))}
end

Compact layout

Tighten py-* on cells and drop the type scale to pack more rows on screen.

Name Email Role Status
Emma Davis emma@example.com Designer Active
Ryan Miller ryan@example.com Developer Busy
Sofia Garcia sofia@example.com Manager Away
James Wilson james@example.com Developer Active
.heex
<div class="w-full max-w-2xl overflow-x-auto">
  <.table class="text-sm">
    <.table_head>
      <:col class="py-2">Name</:col>
      <:col class="py-2">Email</:col>
      <:col class="py-2">Role</:col>
      <:col class="py-2">Status</:col>
    </.table_head>
    <.table_body>
      <.table_row :for={
        user <- [
          %{name: "Emma Davis", email: "emma@example.com", role: "Designer", status: "Active"},
          %{name: "Ryan Miller", email: "ryan@example.com", role: "Developer", status: "Busy"},
          %{name: "Sofia Garcia", email: "sofia@example.com", role: "Manager", status: "Away"},
          %{name: "James Wilson", email: "james@example.com", role: "Developer", status: "Active"}
        ]
      }>
        <:cell class="py-2 font-medium">{user.name}</:cell>
        <:cell class="py-2">{user.email}</:cell>
        <:cell class="py-2">{user.role}</:cell>
        <:cell class="py-2">
          <.badge
            size="sm"
            color={
              case user.status do
                "Active" -> "success"
                "Busy" -> "warning"
                "Away" -> "primary"
                _ -> "primary"
              end
            }
          >
            {user.status}
          </.badge>
        </:cell>
      </.table_row>
    </.table_body>
  </.table>
</div>

Summary row

Use colspan on a :cell to merge columns. Combine with a font-semibold row class for totals.

Item Qty Unit Total
Annual subscription
Pro tier
1 $1,200.00 $1,200.00
Premium support
12 months
1 $240.00 $240.00
Additional seats
5 × $90
5 $90.00 $450.00
Total due $1,890.00
.heex
<.table>
  <.table_head>
    <:col>Item</:col>
    <:col class="text-right">Qty</:col>
    <:col class="text-right">Unit</:col>
    <:col class="text-right">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.note}</div>
      </:cell>
      <:cell class="text-right font-mono">{line.qty}</:cell>
      <:cell class="text-right font-mono">{line.unit}</:cell>
      <:cell class="text-right font-mono">{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">{@invoice.total}</:cell>
    </.table_row>
  </.table_body>
</.table>

Empty state

The component does not render an empty state automatically. Span a single full-width row so the table keeps its frame even with no data.

Name Email Role Status

No users yet

Invite teammates to start collaborating.

.heex
<.table>
  <.table_head>
    <:col>Name</:col>
    <:col>Email</:col>
    <:col>Role</:col>
    <:col>Status</:col>
  </.table_head>
  <.table_body>
    <.table_row :if={@users == []}>
      <:cell colspan="4" class="text-center py-12">
        <div class="flex flex-col items-center gap-3">
          <.icon name="hero-user-group" class="size-8 text-foreground-softest" />
          <div>
            <h3 class="font-medium text-foreground">No users yet</h3>
            <p class="text-sm text-foreground-softer">
              Invite teammates to start collaborating.
            </p>
          </div>
          <.button size="sm">
            <.icon name="hero-plus" class="icon" /> Add user
          </.button>
        </div>
      </:cell>
    </.table_row>

    <.table_row :for={user <- @users}>
      <:cell>{user.name}</:cell>
      <:cell>{user.email}</:cell>
      <:cell>{user.role}</:cell>
      <:cell>{user.status}</:cell>
    </.table_row>
  </.table_body>
</.table>

Loading skeleton

Render placeholder rows while data loads. Sized blocks with animate-pulse avoid layout shift when real rows arrive.

Name Email Role Status
.heex
<div class="w-full max-w-3xl overflow-x-auto">
  <.table>
    <.table_head>
      <:col>Name</:col>
      <:col>Email</:col>
      <:col>Role</:col>
      <:col>Status</:col>
    </.table_head>
    <.table_body>
      <.table_row :for={_ <- 1..5}>
        <:cell>
          <div class="flex items-center gap-3">
            <div class="size-8 rounded-full bg-sunken animate-pulse"></div>
            <div class="h-4 w-36 bg-sunken rounded animate-pulse"></div>
          </div>
        </:cell>
        <:cell>
          <div class="h-4 w-44 bg-sunken rounded animate-pulse"></div>
        </:cell>
        <:cell>
          <div class="h-4 w-28 bg-sunken rounded animate-pulse"></div>
        </:cell>
        <:cell>
          <div class="h-6 w-16 bg-sunken rounded animate-pulse"></div>
        </:cell>
      </.table_row>
    </.table_body>
  </.table>
</div>

Sortable columns

Forward phx-click through the :col slot and drive the chevron icon from a server assign so the indicator stays in sync with the actual ordering.

Name
Email
Role
Alice Johnson alice@example.com Admin
Bob Smith bob@example.com Developer
Charlie Brown charlie@example.com Manager
Diana Lee diana@example.com Designer
.heex
<.table>
  <.table_head>
    <:col phx-click="sort" phx-value-column="name" class="cursor-pointer hover:highlight">
      <div class="flex items-center gap-1">
        Name
        <.icon name={sort_icon(@sort, :name)} class="size-3.5 text-foreground-softer" />
      </div>
    </:col>
    <:col phx-click="sort" phx-value-column="email" class="cursor-pointer hover:highlight">
      <div class="flex items-center gap-1">
        Email
        <.icon name={sort_icon(@sort, :email)} class="size-3.5 text-foreground-softer" />
      </div>
    </:col>
    <:col phx-click="sort" phx-value-column="role" class="cursor-pointer hover:highlight">
      <div class="flex items-center gap-1">
        Role
        <.icon name={sort_icon(@sort, :role)} class="size-3.5 text-foreground-softer" />
      </div>
    </:col>
  </.table_head>
  <.table_body>
    <.table_row :for={user <- @users}>
      <:cell class="font-medium">{user.name}</:cell>
      <:cell>{user.email}</:cell>
      <:cell>{user.role}</:cell>
    </.table_row>
  </.table_body>
</.table>

# Pick the chevron icon from server state so the indicator stays in sync
# with the actual ordering.
defp sort_icon(%{column: c, direction: :asc}, c), do: "huge-arrow-up-01"
defp sort_icon(%{column: c, direction: :desc}, c), do: "huge-arrow-down-01"
defp sort_icon(_, _), do: "huge-arrow-up-down"

Inventory dashboard

A composition that combines colgroup widths, sortable headers, row selection, status badges, and a per-row action.

Products

6 items in stock

SKU Status
ELC-001 Wireless Mouse Electronics In Stock 150
ELC-014 Mechanical Keyboard Electronics Low Stock 15
ACC-072 USB-C Hub Accessories In Stock 75
ELC-027 27" Monitor Electronics On Order 0
ACC-038 Laptop Stand Accessories Out of Stock 0
FRN-101 Ergonomic Chair Furniture In Stock 45
.heex
<header class="flex items-center justify-between border-b border-base p-4">
  <div>
    <h3 class="font-semibold">Products</h3>
    <p class="text-sm text-foreground-softer">{length(@products)} items in stock</p>
  </div>

  <div class="flex items-center gap-2">
    <.input name="search" placeholder="Search products">
      <:inner_prefix>
        <.icon name="huge-search-01" class="size-4 text-foreground-softer" />
      </:inner_prefix>
    </.input>
    <.button variant="outline" size="sm">
      <.icon name="huge-filter" class="icon" /> Filter
    </.button>
    <.button size="sm">
      <.icon name="huge-add-01" class="icon" /> Add product
    </.button>
  </div>
</header>

<.table>
  <colgroup>
    <col class="w-10" />
    <col class="w-28" />
    <col />
    <col class="w-32" />
    <col class="w-32" />
    <col class="w-20" />
    <col class="w-12" />
  </colgroup>
  <.table_head>
    <:col class="pl-4!">
      <div class="flex items-center">
        <.checkbox
          name="select-all"
          onclick="this.closest('table').querySelectorAll('tbody input[type=checkbox]').forEach(c => c.checked = this.checked)"
        />
      </div>
    </:col>
    <:col>SKU</:col>
    <:col phx-click="sort" phx-value-column="product_name">
      <button class="-mx-2 flex cursor-pointer items-center gap-1 rounded-base px-2 py-1 hover:highlight">
        Product
        <.icon name={sort_icon(@sort, :product_name)} class="size-3.5 text-foreground-softest" />
      </button>
    </:col>
    <:col phx-click="sort" phx-value-column="category">
      <button class="-mx-2 flex cursor-pointer items-center gap-1 rounded-base px-2 py-1 hover:highlight">
        Category
        <.icon name={sort_icon(@sort, :category)} class="size-3.5 text-foreground-softest" />
      </button>
    </:col>
    <:col>Status</:col>
    <:col class="text-center" phx-click="sort" phx-value-column="quantity">
      <button class="mx-auto flex cursor-pointer items-center gap-1 rounded-base px-2 py-1 hover:highlight">
        Qty
        <.icon name={sort_icon(@sort, :quantity)} class="size-3.5 text-foreground-softest" />
      </button>
    </:col>
    <:col class="pr-4!"></:col>
  </.table_head>

  <.table_body>
    <.table_row
      :for={product <- @products}
      class="group hover:highlight has-checked:highlight-strong"
    >
      <:cell class="relative pl-4!">
        <span class="absolute inset-y-0 left-0 hidden w-[2px] bg-primary group-has-checked:block">
        </span>
        <div class="flex items-center">
          <.checkbox name={"product-#{product.id}"} />
        </div>
      </:cell>
      <:cell class="font-mono text-foreground-softer text-xs">{product.sku}</:cell>
      <:cell class="font-medium">{product.product_name}</:cell>
      <:cell>{product.category}</:cell>
      <:cell>
        <.badge color={status_color(product.stock_status)} variant="soft">
          {product.stock_status}
        </.badge>
      </:cell>
      <:cell class="text-center font-mono">{product.quantity}</:cell>
      <:cell class="pr-4! text-right">
        <.button variant="ghost" size="icon-sm" aria-label="View product">
          <.icon name="huge-eye" class="icon" />
        </.button>
      </:cell>
    </.table_row>
  </.table_body>
</.table>