Table
Displays data in a tabular format
<sa-table>
<sa-table-caption>Total Active Value includes Confirmed and Pending bookings only.</sa-table-caption>
<sa-table-header>
<sa-table-row>
<sa-table-head class="w-[100px]">Booking #</sa-table-head>
<sa-table-head>Status</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings)
{
<sa-table-row>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>
<sa-badge variant="@GetBadgeVariant(booking.Status)">
@booking.Status.ToString()
</sa-badge>
</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">
@booking.Amount.ToString("N2")
</sa-table-cell>
</sa-table-row>
}
</sa-table-body>
<sa-table-footer>
<sa-table-row>
<sa-table-cell colspan="3">
Total Active Value
</sa-table-cell>
<sa-table-cell class="text-right">
@{
var activeValue = StaticData.Bookings
.Where(b => b.Status != BookingStatus.Cancelled)
.Select(b => b.Amount)
.Sum();
}
@activeValue.ToString("N2")
</sa-table-cell>
</sa-table-row>
</sa-table-footer>
</sa-table>
@functions
{
BadgeVariant GetBadgeVariant(BookingStatus status) => status switch
{
BookingStatus.Cancelled => BadgeVariant.Destructive,
BookingStatus.Pending => BadgeVariant.Secondary,
_ => BadgeVariant.Default
};
}Usage
Compose the table from the caption, header, body and footer tags, mirroring the native table structure.
<sa-table>
<sa-table-caption>...</sa-table-caption>
<sa-table-header>
<sa-table-row>
<sa-table-head>...</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
<sa-table-row>
<sa-table-cell>...</sa-table-cell>
</sa-table-row>
</sa-table-body>
<sa-table-footer>
<sa-table-row>
<sa-table-cell>...</sa-table-cell>
</sa-table-row>
</sa-table-footer>
</sa-table>Examples
With border
Wrap the table in a bordered container to give it an outer border.
<div class="w-full overflow-hidden rounded-lg border">
<sa-table>
<sa-table-header>
<sa-table-row>
<sa-table-head class="w-[100px]">Booking #</sa-table-head>
<sa-table-head>Status</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings)
{
<sa-table-row>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>
<sa-badge variant="@GetBadgeVariant(booking.Status)">
@booking.Status.ToString()
</sa-badge>
</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">
@booking.Amount.ToString("N2")
</sa-table-cell>
</sa-table-row>
}
</sa-table-body>
<sa-table-footer>
<sa-table-row>
<sa-table-cell colspan="3">
Total Active Value
</sa-table-cell>
<sa-table-cell class="text-right">
@{
var activeValue = StaticData.Bookings
.Where(b => b.Status != BookingStatus.Cancelled)
.Select(b => b.Amount)
.Sum();
}
@activeValue.ToString("N2")
</sa-table-cell>
</sa-table-row>
</sa-table-footer>
</sa-table>
</div>
@functions
{
BadgeVariant GetBadgeVariant(BookingStatus status) => status switch
{
BookingStatus.Cancelled => BadgeVariant.Destructive,
BookingStatus.Pending => BadgeVariant.Secondary,
_ => BadgeVariant.Default
};
}With select
Wrap the table in <sa-table-selection> and add checkboxes to the header and body rows to make rows selectable.
<sa-table>
<sa-table-caption>Total Active Value includes Confirmed and Pending bookings only.</sa-table-caption>
<sa-table-header>
<sa-table-row>
<sa-table-head class="w-[100px]">Booking #</sa-table-head>
<sa-table-head>Status</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings)
{
<sa-table-row>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>
<sa-select size="SelectSize.Small">
@foreach (var selectListItem in bookingStatusList)
{
var selected = BookingStatus.TryParse(selectListItem.Value, out BookingStatus status) && booking.Status == status;
<option value="@selectListItem.Value" selected="@(selected)">@selectListItem.Text</option>
}
</sa-select>
</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">
@booking.Amount.ToString("N2")
</sa-table-cell>
</sa-table-row>
}
</sa-table-body>
<sa-table-footer>
<sa-table-row>
<sa-table-cell colspan="3">
Total Active Value
</sa-table-cell>
<sa-table-cell class="text-right">
@{
var activeValue = StaticData.Bookings
.Where(b => b.Status != BookingStatus.Cancelled)
.Select(b => b.Amount)
.Sum();
}
@activeValue.ToString("N2")
</sa-table-cell>
</sa-table-row>
</sa-table-footer>
</sa-table>Row selection
Wrap the table in <sa-table-selection> to add row selection. A checkbox in the table's header row acts as the select-all, a checkbox in a body row selects that row, and selected rows are highlighted. The selection posts as ordinary checkbox form data, so no extra wiring is needed to submit it.
<sa-table-selection id="booking-selection">
<sa-table>
<sa-table-header>
<sa-table-row>
<sa-table-head class="w-12">
<sa-input type="checkbox" aria-label="Select all bookings"/>
</sa-table-head>
<sa-table-head class="w-[100px]">Booking #</sa-table-head>
<sa-table-head>Status</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings)
{
<sa-table-row>
<sa-table-cell>
<sa-input type="checkbox" name="selectedBookings" value="@booking.Id"
checked="@(booking.Status == BookingStatus.Pending)"
aria-label="Select booking @booking.Id"/>
</sa-table-cell>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>@booking.Status.ToString()</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">@booking.Amount.ToString("N2")</sa-table-cell>
</sa-table-row>
}
</sa-table-body>
</sa-table>
</sa-table-selection>
<sa-button id="booking-selection-delete" variant="ButtonVariant.Destructive" class="mt-4" hidden>
Delete selected
</sa-button>
<script>
(() => {
const selection = document.getElementById("booking-selection");
const deleteButton = document.getElementById("booking-selection-delete");
const syncButton = () => deleteButton.hidden = selection.selectedValues.length === 0;
// selection-change fires on every user-driven change; the initial
// server-rendered selection is read once the component is defined.
selection.addEventListener("selection-change", syncButton);
customElements.whenDefined("sel-table-selection").then(syncButton);
deleteButton.addEventListener("click", () =>
alert(`Delete bookings:\n${selection.selectedValues.join("\n")}`));
})();
</script>Row details
Wrap the table in <sa-table-row-details> to let rows expand and show more information below them. Place an <sa-table-row-detail> after each expandable row, holding whatever the row should reveal, and an <sa-table-row-detail-toggle> in a cell of that row. A toggle with all="true" in a header cell expands and collapses every row. Set expanded="true" on a detail row to render it open, as the second booking here is.
Each toggle is a button with aria-expanded kept in sync, so screen readers announce the state. Give each one an aria-label that names its row, such as "Show details for TRP-4822".
<div class="w-full overflow-hidden rounded-lg border">
<sa-table-row-details>
<sa-table>
<sa-table-header>
<sa-table-row>
<sa-table-head class="w-px">
<sa-table-row-detail-toggle all="true"/>
</sa-table-head>
<sa-table-head>Booking #</sa-table-head>
<sa-table-head>Status</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings)
{
var details = StaticData.DetailsFor(booking.Id);
<sa-table-row>
<sa-table-cell class="w-px">
<sa-table-row-detail-toggle aria-label="Show details for @booking.Id"/>
</sa-table-cell>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>@booking.Status</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">@booking.Amount.ToString("N2")</sa-table-cell>
</sa-table-row>
<sa-table-row-detail expanded="@(booking.Id == "TRP-4822")">
<dl class="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-3">
<div class="flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Lead traveller</dt>
<dd>@details.Traveller</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Email</dt>
<dd>@details.Email</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Party</dt>
<dd>@details.Party</dd>
</div>
<div class="col-span-full flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Notes</dt>
<dd>@details.Notes</dd>
</div>
</dl>
</sa-table-row-detail>
}
</sa-table-body>
</sa-table>
</sa-table-row-details>
</div>Row details with row click
Set row-click="true" to let a click anywhere in a row toggle its details. Links, buttons and form controls in the row keep their own behavior, so the edit button here still works. A toggle can sit in any cell, such as an actions column beside the edit button. This example also opens one row at a time with expand-mode, marks the expanded row with a rail instead of a band, and starts the details at the table's edge with inset.
<div class="w-full overflow-hidden rounded-lg border">
<sa-table-row-details row-click="true"
expand-mode="TableRowDetailExpandMode.Single"
emphasis="TableRowDetailEmphasis.Rail"
inset="TableRowDetailInset.Bleed">
<sa-table>
<sa-table-header>
<sa-table-row>
<sa-table-head>Booking #</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
<sa-table-head class="w-px"><span class="sr-only">Actions</span></sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings.Take(4))
{
<sa-table-row>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">@booking.Amount.ToString("N2")</sa-table-cell>
<sa-table-cell class="w-px whitespace-nowrap">
<sa-linkbutton href="#" variant="ButtonVariant.Outline" size="ButtonSize.IconSmall"
aria-label="Edit @booking.Id">
<sa-icon name="square-pen"/>
</sa-linkbutton>
<sa-table-row-detail-toggle aria-label="Show itinerary for @booking.Id"/>
</sa-table-cell>
</sa-table-row>
<sa-table-row-detail>
<ul class="flex flex-col divide-y">
@foreach (var segment in StaticData.DetailsFor(booking.Id).Itinerary)
{
<li class="flex items-center gap-4 py-2">
<span class="text-muted-foreground w-16">@segment.Date</span>
<span class="flex-1">
<span class="font-medium">@segment.Type</span> · @segment.Description
</span>
<span class="tabular-nums">@segment.Amount</span>
</li>
}
</ul>
</sa-table-row-detail>
}
</sa-table-body>
</sa-table>
</sa-table-row-details>
</div>Row details divider
Set divider to choose the line between an expanded row and its details. Inset, the default, starts and ends the line where the details content does, so the details read as part of their row. FullWidth draws it across the table like the lines between rows, and None leaves it out. The <sa-data-grid-row-detail> of a data grid takes the same attribute.
var dividers = new[]
{
(Divider: TableRowDetailDivider.Inset, Label: "Inset (default)"),
(Divider: TableRowDetailDivider.FullWidth, Label: "FullWidth"),
(Divider: TableRowDetailDivider.None, Label: "None"),
};
}
<div class="flex w-full flex-col gap-6">
@foreach (var (divider, label) in dividers)
{
<div class="flex flex-col gap-2">
<p class="text-muted-foreground text-sm">@label</p>
<div class="overflow-hidden rounded-lg border">
<sa-table-row-details divider="divider">
<sa-table>
<sa-table-header>
<sa-table-row>
<sa-table-head class="w-px"><span class="sr-only">Details</span></sa-table-head>
<sa-table-head>Booking #</sa-table-head>
<sa-table-head>Destination</sa-table-head>
<sa-table-head class="text-right">Amount</sa-table-head>
</sa-table-row>
</sa-table-header>
<sa-table-body>
@foreach (var booking in StaticData.Bookings.Take(2))
{
var details = StaticData.DetailsFor(booking.Id);
<sa-table-row>
<sa-table-cell class="w-px">
<sa-table-row-detail-toggle aria-label="Show details for @booking.Id"/>
</sa-table-cell>
<sa-table-cell class="font-medium">@booking.Id</sa-table-cell>
<sa-table-cell>@booking.Destination</sa-table-cell>
<sa-table-cell class="text-right">@booking.Amount.ToString("N2")</sa-table-cell>
</sa-table-row>
<sa-table-row-detail expanded="@(booking == StaticData.Bookings.First())">
<dl class="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-3">
<div class="flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Lead traveller</dt>
<dd>@details.Traveller</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Email</dt>
<dd>@details.Email</dd>
</div>
<div class="flex flex-col gap-0.5">
<dt class="text-muted-foreground text-xs">Party</dt>
<dd>@details.Party</dd>
</div>
</dl>
</sa-table-row-detail>
}
</sa-table-body>
</sa-table>
</sa-table-row-details>
</div>
</div>
}
</div>Loading row details with htmx
Expanding a row raises a bubbling row-detail-expand event on its <sa-table-row-detail> once the details are visible, and collapsing it raises row-detail-collapse. The event's detail holds the expandable row and the detailRow. To load a row's details from the server the first time it expands, put the htmx attributes on the detail row itself, where the event fires, and target its content. The placeholder shows until the response arrives.
<sa-table-row-detail hx-get="/bookings/TRP-4822/details"
hx-trigger="row-detail-expand once"
hx-target="find [data-slot=table-row-detail-content]">
<sa-skeleton class="h-4 w-2/3"/>
</sa-table-row-detail>Drop once to reload the details every time the row expands. A row rendered with expanded="true" raises no event, so render its content directly. Rows that htmx swaps in or appends work without any further setup.
In htmx 2, hx-target is inherited by htmx elements inside the details. To avoid that, put the attributes on an element inside the detail row instead and listen for the event on the row: hx-trigger="row-detail-expand from:closest tr once" in htmx 2, or hx-trigger="row-detail-expand from:<closest tr/> once" in htmx 4.
API Reference
<sa-table>
Renders a <table> element wrapped in a scrollable <div> container. The class attribute is applied to the <table> element, and other attributes are applied to the container.
<sa-table-caption>
Renders a <caption> element.
<sa-table-header>
Renders a <thead> element.
<sa-table-body>
Renders a <tbody> element.
<sa-table-footer>
Renders a <tfoot> element.
<sa-table-row>
Renders a <tr> element.
<sa-table-head>
Renders a <th> element, and forwards standard <th> attributes such as colspan and scope.
<sa-table-cell>
Renders a <td> element, and forwards standard <td> attributes such as colspan.
<sa-table-selection>
Adds row selection to the table it wraps, rendered as a sel-table-selection web component. The checkbox in the table's header row acts as the select-all, and a checkbox in a body row selects that row. Selected rows get data-state="selected", the select-all reflects the checked/indeterminate state, and the selection posts as ordinary checkbox form data. From JavaScript, read the current selection from the element's selectedValues property, or listen for its bubbling selection-change event.
<sa-table-row-details>
Lets the rows of the table it wraps expand to show details, rendered as a sel-table-row-details web component. It raises the bubbling row-detail-expand and row-detail-collapse events on each detail row as it opens or closes. From JavaScript, call expand(detailRow), collapse(detailRow), toggle(detailRow), expandAll() or collapseAll() on the element.
Prop
Type
<sa-table-row-detail>
Renders a <tr> element with one cell spanning every column, holding the details of the row before it. It renders with hidden until its row expands. The details content stays in view when the table scrolls sideways.
Prop
Type
<sa-table-row-detail-toggle>
Renders a <button> element styled as a ghost icon button, with a chevron that turns when its row expands. It controls the <sa-table-row-detail> after its own row, or the detail row named by an aria-controls attribute. The aria-label defaults to "Toggle row details", or "Toggle all row details" with all.
Prop
Type