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
Cells can hold form controls, such as a select that changes each booking's status.
<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
Set selection="true" on the table to add row selection. An <sa-table-selection-head> in the header row selects or clears every row, an <sa-table-selection-cell> in a body row selects that row, and selected rows are highlighted. The selection posts as ordinary checkbox form data under each cell's name, so no extra wiring is needed to submit it.
<sa-table id="booking-selection" selection="true">
<sa-table-header>
<sa-table-row>
<sa-table-selection-head label="Select all bookings"/>
<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-selection-cell name="selectedBookings" value="@booking.Id"
checked="@(booking.Status == BookingStatus.Pending)"
label="Select booking @booking.Id"/>
<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-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;
// rowselectionchange fires on every user-driven change; the initial
// server-rendered selection is read once the component is defined.
selection.addEventListener("rowselectionchange", syncButton);
customElements.whenDefined("sel-table").then(syncButton);
deleteButton.addEventListener("click", () =>
alert(`Delete bookings:\n${selection.selectedValues.join("\n")}`));
})();
</script>Row details
Set row-details="true" on the table 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="true">
<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>
</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 row-detail-expand-mode, marks the expanded row with a rail instead of a band with row-detail-emphasis, and starts the details at the table's edge with row-detail-inset.
<div class="w-full overflow-hidden rounded-lg border">
<sa-table row-details="true" row-click="true"
row-detail-expand-mode="TableRowDetailExpandMode.Single"
row-detail-emphasis="TableRowDetailEmphasis.Rail"
row-detail-inset="TableRowDetailInset.Bleed">
<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-button-link href="#" variant="ButtonVariant.Outline" size="ButtonSize.IconSmall"
aria-label="Edit @booking.Id">
<sa-icon name="square-pen"/>
</sa-button-link>
<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>
</div>Row details divider
Set row-detail-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. A data grid's <sa-data-grid-row-detail> takes the same setting as divider.
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="true" row-detail-divider="divider">
<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>
</div>
</div>
}
</div>Loading row details with htmx
Expanding a row raises a bubbling rowdetailexpand event on its <sa-table-row-detail> once the details are visible, and collapsing it raises rowdetailcollapse. 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="rowdetailexpand 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="rowdetailexpand from:closest tr once" in htmx 2, or hx-trigger="rowdetailexpand from:<closest tr/> once" in htmx 4.
API Reference
<sa-table>
Renders a <table> element wrapped in a container that scrolls sideways when the table is too wide. The class attribute is applied to the <table> element, and other attributes, such as id, are applied to the element around it. Row selection and row details require stellar-admin.js.
With selection or row-details, the table renders inside a web component, which takes the id you give <sa-table>. Selected rows get data-state="selected", and the select-all reflects the checked or indeterminate state. From JavaScript, get that element with document.getElementById() and read the current selection from its selectedValues property, or listen for its bubbling rowselectionchange event, whose event.detail.values holds the selected values. Call expand(detailRow), collapse(detailRow), toggle(detailRow), expandAll() or collapseAll() on the element to change which rows are expanded. Each detail row raises the bubbling rowdetailexpand and rowdetailcollapse events as it opens or closes.
Prop
Type
<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-head>
Renders a header cell with a checkbox that selects or clears every row. Use it in an <sa-table> with selection, above the column of <sa-table-selection-cell> cells.
Prop
Type
<sa-table-selection-cell>
Renders a cell with a checkbox that selects its row. Use it in an <sa-table> with selection. The checked checkboxes post their values under name.
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's <sa-table-row-detail-toggle> expands it, and raises the bubbling rowdetailexpand and rowdetailcollapse events as it opens or closes. 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, unless you give it content. Place it in any cell of the row. 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