Files
Htmx/docs/Components/Table.md
T
2026-05-04 19:57:48 +05:00

5.4 KiB
Raw Blame History

Table

A styled HTML data table with a header row, optional caption, optional footer row, and one or more data rows. All data is plain strings.


HTML structure

div.overflow-auto.rounded-md.border.border-border
  table.w-full.text-sm.caption-bottom
    caption.mt-4.text-sm.text-muted-foreground   ← omitted when empty
      {caption}
    thead
      tr.border-b.bg-muted/50
        th.h-12.px-4.text-left.font-medium       ← one per header
          {header}
    tbody
      tr.border-b.hover:bg-muted/40              ← one per row; last row has no border
        td.p-4                                   ← one per cell; raw HTML
          {cell}
    tfoot                                        ← omitted when empty
      tr
        td[colspan=N].p-4.text-muted-foreground
          {footer}

CSS mechanics

Class Effect
overflow-auto on wrapper Horizontal scroll on small screens
bg-muted/50 on header Slightly tinted header row
hover:bg-muted/40 on data rows Subtle hover highlight
border-b on rows Row separator lines
caption-bottom Caption appears below the table

Constructor signature

public Table(
    IEnumerable<string> headers,
    IEnumerable<IEnumerable<string>> rows,
    string caption = "",
    string footer  = "")
Parameter Description
headers Column heading strings
rows Each inner IEnumerable<string> is one row; cells are raw HTML
caption Optional caption below the table
footer Optional footer cell (spans all columns)

Usage examples

Basic data table

new Table(
    headers: new[] { "Name", "Email", "Role" },
    rows: users.Select(u => new[] { u.DisplayName ?? "", u.Email, u.Role }))
new Table(
    headers: new[] { "Product", "Price", "Stock" },
    rows: products.Select(p => new[]
    {
        p.Name,
        $"${p.Price:F2}",
        p.Stock.ToString()
    }),
    caption: $"Showing {products.Count} products",
    footer:  "Prices include VAT")

Cells with HTML content (e.g. badges)

// Pre-render a Badge to HTML string
string ActiveBadge()
{
    var buf = new System.Buffers.ArrayBufferWriter<byte>();
    new Badge("Active").Render(new HtmxRenderContext(buf));
    return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
}

new Table(
    headers: new[] { "Name", "Status" },
    rows: users.Select(u => new[]
    {
        System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
        u.IsActive ? ActiveBadge() : ""
    }))

With action buttons per row

string EditBtn(string id) => $"""
    <button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>
""";

new Table(
    headers: new[] { "Name", "Actions" },
    rows: users.Select(u => new[]
    {
        System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
        EditBtn(u.Id!)
    }))

Tips and tricks

  • Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
  • Use System.Web.HttpUtility.HtmlEncode(value) or System.Net.WebUtility.HtmlEncode(value) for any untrusted data.
  • Pair with Pagination below the table for large datasets.
  • For sortable columns, replace header strings with anchor tags containing HTMX sort-request attributes.
  • The footer string spans all columns — use it for totals, notes, or a "load more" link.
  • The footer string spans all columns — use it for totals, notes, or a "load more" link.

Complete page example

Templates/AdminUsersPage.htmx

<div class="max-w-5xl mx-auto py-10">
  <div class="flex items-center justify-between mb-6">
  <h1 class="text-2xl font-bold">Users</h1>
  $$InviteBtn$$
  </div>
  $$UsersTable$$
</div>

Templates/AdminUsersPage.htmx.cs

namespace Htmx.ApiDemo.Templates;

public sealed class AdminUsersPage : AdminUsersPageBase
{
  private readonly IHtmxComponent _table;
  private readonly IHtmxComponent _invite;

  public AdminUsersPage(IEnumerable<ApplicationUser> users, int total)
  {
    _invite = new Components.Button(
      "Invite user",
      variant: "default",
      extraAttributes: """hx-get="/admin/users/invite" hx-target="#modal" hx-swap="innerHTML" """);

    var rows = users.Select(u => new[]
    {
      System.Net.WebUtility.HtmlEncode(u.DisplayName ?? ""),
      System.Net.WebUtility.HtmlEncode(u.Email),
      u.CreatedAt.ToString("yyyy-MM-dd"),
      $"""<button class="text-destructive text-xs" hx-delete="/admin/users/{u.Id}" hx-confirm="Delete this user?">Delete</button>""",
    });

    _table = new Components.Table(
      caption:  "All registered accounts",
      headers:  new[] { "Name", "Email", "Joined", "Actions" },
      rows:     rows,
      footer:   $"{total} users total");
  }

  protected override void RenderInviteBtn(HtmxRenderContext ctx)   => _invite.Render(ctx.Next());
  protected override void RenderUsersTable(HtmxRenderContext ctx)  => _table.Render(ctx.Next());
}

GET handler

[Handler]
[MapGet("/admin/users")]
public static partial class GetAdminUsersHandler
{
  public record Query();
  private static async Task<IResult> HandleAsync(
    Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
  {
    var users = await db.GetAllUsersAsync(ct);
    var list  = users.ToList();
    return await ctx.WriteHtmxPage(
      new AdminUsersPage(list, list.Count), title: "Admin  Users");
  }
}