Skip to content

Table

Table
Thymeleaf source
<sl:table>
<caption>A list of your recent invoices.</caption>
<thead>
<tr>
<th>Invoice</th>
<th>Status</th>
<th>Method</th>
<sl:table-head align="end">Amount</sl:table-head>
</tr>
</thead>
<tbody>
<tr>
<td>INV001</td>
<td><sl:badge variant="secondary">Paid</sl:badge></td>
<td>Credit card</td>
<td data-align="end">€250.00</td>
</tr>
<tr>
<td>INV002</td>
<td><sl:badge variant="outline">Pending</sl:badge></td>
<td>Bank transfer</td>
<td data-align="end">€150.00</td>
</tr>
<tr>
<td>INV003</td>
<td><sl:badge variant="destructive">Overdue</sl:badge></td>
<td>Credit card</td>
<td data-align="end">€350.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td data-align="end">€750.00</td>
</tr>
</tfoot>
</sl:table>
Rendered HTML
<div class="table-container">
<table class="table">
<caption>A list of your recent invoices.</caption>
<thead>
<tr>
<th>Invoice</th>
<th>Status</th>
<th>Method</th>
<th class="table-head" data-align="end">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>INV001</td>
<td>
<span class="badge" data-variant="secondary">Paid</span>
</td>
<td>Credit card</td>
<td data-align="end">€250.00</td>
</tr>
<tr>
<td>INV002</td>
<td>
<span class="badge" data-variant="outline">Pending</span>
</td>
<td>Bank transfer</td>
<td data-align="end">€150.00</td>
</tr>
<tr>
<td>INV003</td>
<td>
<span class="badge" data-variant="destructive">Overdue</span>
</td>
<td>Credit card</td>
<td data-align="end">€350.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td data-align="end">€750.00</td>
</tr>
</tfoot>
</table>
</div>

<sl:table> styles the plain table elements you write inside it: caption, thead, tbody, tfoot, tr, th and td. There is no component per row or cell, as there is in shadcn/ui, so a row is the th:each you already write:

<sl:table>
<thead>
<tr><th>Name</th><th>Email</th></tr>
</thead>
<tbody>
<tr th:each="person : ${people}">
<td th:text="${person.name}">Ada Lovelace</td>
<td th:text="${person.email}">ada@example.com</td>
</tr>
</tbody>
</sl:table>

Attributes on <sl:table> go to the table; it sits in a container that scrolls sideways when the table is wider than the page. A table nested in a cell keeps its own look, so put it in an <sl:table> of its own.

<sl:table-head align="end"> puts a header’s label at the end of its cell. The cells below take the same with data-align="end" (or "center"), which works without utility classes, so also with the standalone stylesheet. Use it for amounts and other numbers, so their digits line up.

A table wider than the page scrolls sideways inside its container. A keyboard user can scroll it too: Chromium and Firefox make the container a tab stop of its own when nothing in it can take the focus, and the arrow keys then scroll it; when a sort link, a checkbox or a row’s menu button in it takes the focus, the browser scrolls it into view. The container has no tabindex of its own, which would add a tab stop to every table that fits.

WebKit, checked with Playwright’s build of it, does neither for a container of plain text: there a wide table with nothing to focus in it cannot be reached with the keyboard, and with Option+Tab (Safari’s Press Tab to highlight each item) its links can, as above. axe reports such a table as scrollable-region-focusable. Keep a table that is only text from growing wider than a phone, or give its rows something to focus.

The rows of a Spring application live on the server, which sends one page of them. So a sortable header is a link that asks the server for the page in another order, not a script that reorders the rows in the browser:

Sorted on the serverSorted by name, in a request for /people?q=a&sort=name,asc. Each link keeps q.
Thymeleaf source
<sl:table th:page="${people}">
<thead>
<tr>
<sl:table-head sort="name">Name</sl:table-head>
<sl:table-head sort="email">Email</sl:table-head>
<sl:table-head sort="orders" align="end">Orders</sl:table-head>
</tr>
</thead>
<tbody>
<tr th:each="person : ${people}">
<td th:text="${person.name}">Ada Lovelace</td>
<td th:text="${person.email}">ada@example.com</td>
<td data-align="end" th:text="${person.orders}">3</td>
</tr>
</tbody>
</sl:table>
Rendered HTML
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="table-head" aria-sort="ascending">
<a class="button table-sort" data-variant="ghost" data-size="sm" href="/people?q=a&amp;sort=name,desc" id="sort-name">
Name
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m5 12 7-7 7 7"></path>
<path d="M12 19V5"></path>
</svg>
</a>
</th>
<th class="table-head">
<a class="button table-sort" data-variant="ghost" data-size="sm" href="/people?q=a&amp;sort=email,asc" id="sort-email">
Email
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</a>
</th>
<th class="table-head" data-align="end">
<a class="button table-sort" data-variant="ghost" data-size="sm" href="/people?q=a&amp;sort=orders,asc" id="sort-orders">
Orders
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ada Lovelace</td>
<td>ada@example.com</td>
<td data-align="end">3</td>
</tr>
<tr>
<td>Alan Turing</td>
<td>alan@example.com</td>
<td data-align="end">12</td>
</tr>
<tr>
<td>Grace Hopper</td>
<td>grace@example.com</td>
<td data-align="end">7</td>
</tr>
</tbody>
</table>
</div>

<sl:table-head sort="name"> links to the same page with sort=name,asc, or sort=name,desc when the table is sorted by name ascending already. The link keeps every other parameter in the address (a search, the page size) and drops the page number, so a new order starts on the first page. The sorted column gets aria-sort, which screen readers announce, and an arrow; the others show both chevrons. A click on the sorted column reverses it; there is no unsorted state, because the default order is the controller’s.

The controller takes a Pageable, which Spring Data fills from those parameters, and puts the page in the model:

@GetMapping("/people")
String people(@PageableDefault(sort = "name") Pageable pageable, Model model) {
model.addAttribute("people", personRepository.findAll(pageable));
return "people";
}
<sl:table th:page="${people}">
<thead>
<tr>
<sl:table-head sort="name">Name</sl:table-head>
<sl:table-head sort="email">Email</sl:table-head>
</tr>
</thead>
...
</sl:table>

th:page takes the Page (or Slice), whose Sort tells the headers how the rows are sorted, the default from @PageableDefault included. Only the first sort key is shown. spring-data-commons is all it needs, not a Spring Data module: rows that do not come from a repository can be wrapped in a new PageImpl<>(rows, pageable, total).

Without th:page the headers read the order from the request’s sort parameter, so a table sorts without Spring Data too; the controller then reads that parameter itself.

The links use the parameter names your Pageable reads. Shadleaf takes them from Spring Boot’s properties: spring.data.web.sort.sort-parameter, and for the page and size links spring.data.web.pageable.page-parameter, size-parameter, prefix, qualifier-delimiter and one-indexed-parameters. If you configure Spring Data’s resolvers in code instead, declare a PagingParameters bean with the same values.

Two sortable tables on one page need their own parameters. Give the controller’s second Pageable a @Qualifier, and the table the same qualifier:

@GetMapping("/team")
String team(Pageable pageable, @Qualifier("members") Pageable members, Model model) { ... }
<sl:table th:page="${members}" qualifier="members"> ... </sl:table>

Its headers then link to members_sort=email,asc and leave the other table’s sort and page alone.

The sort parameter is part of the address, so anyone can edit it. Spring Data passes an unknown property on to the repository, which fails with an error page (?sort=password is an error at best). So the controller checks the order against the columns the table offers before it queries, and falls back to the default order when it asks for anything else:

@Controller
class PeopleController {
// The properties of the table's <sl:table-head sort="..."> columns.
private static final Set<String> SORTABLE = Set.of("name", "email", "orders");
private final PersonRepository personRepository;
PeopleController(PersonRepository personRepository) {
this.personRepository = personRepository;
}
@GetMapping("/people")
String people(@PageableDefault(sort = "name") Pageable pageable, Model model) {
model.addAttribute("people", personRepository.findAll(sortable(pageable)));
return "people";
}
/** The pageable as requested, or sorted by name when it names a property the table does not offer. */
private static Pageable sortable(Pageable pageable) {
boolean allowed = pageable.getSort().stream().allMatch(order -> SORTABLE.contains(order.getProperty()));
return allowed
? pageable
: PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by("name"));
}
}

The page the repository returns carries the order it was sorted by, so the table shows the fallback correctly too.

Rows with the same value in the sorted column (two people with 59 orders) come back from the database in no particular order, and not necessarily the same one for every page. Once the table has pages, such a row can show up on two pages, or on none. Add a unique key the reader does not see, such as the id, as the last sort key before you query:

@GetMapping("/people")
String people(@PageableDefault(sort = "name") Pageable pageable, Model model) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
sortable(pageable).getSort().and(Sort.by("id")));
model.addAttribute("people", personRepository.findAll(stable));
return "people";
}

The headers show only the first sort key, and the links are built from the request rather than from the page’s Sort, so the id never shows up in the address. Sample-01’s /people page does this, and its test follows Next from the first page to the last and finds every person exactly once.

An enum stored as a string (@Enumerated(EnumType.STRING)) sorts alphabetically: Admin, Guest, Member, Owner. To sort roles by rank, sort the database by the rank:

  • Store the rank. An AttributeConverter writes the enum as a number, so sort=role sorts by rank and the header needs nothing else. Sample-01 does this:

    public enum Role {
    OWNER(1), ADMIN(2), MEMBER(3), GUEST(4);
    ...
    }
    @Converter
    class RoleConverter implements AttributeConverter<Role, Integer> {
    public Integer convertToDatabaseColumn(Role role) { return role == null ? null : role.getRank(); }
    public Role convertToEntityAttribute(Integer rank) { return rank == null ? null : Role.ofRank(rank); }
    }
    @Convert(converter = RoleConverter.class)
    private Role role;

    An explicit rank, unlike EnumType.ORDINAL, survives reordering the enum’s constants.

  • Sort by an expression. Keep the string column and order by a CASE expression in a query of your own (order by case p.role when 'OWNER' then 1 when 'ADMIN' then 2 ... end), or with JpaSort.unsafe(...). The controller then maps the role property of the requested Sort to that expression, and the Page it gets back carries the expression, not role: build the page the template gets with the requested Pageable (new PageImpl<>(page.getContent(), pageable, page.getTotalElements())) so the header still shows the order.

Each sort link has an id (sort-name, or members-sort-name with a qualifier), so when htmx replaces the table it gives the focus back to the link that was clicked. Tables with htmx puts it together.

A search or filter above the table is a GET form the controller reads (?q=ada). Put <sl:query-params except="q"/> in it, so sending it keeps the table’s order and page size and starts on the first page; the sort and page links keep q by themselves. To search as the user types, see Tables with htmx.

<sl:table-empty> is the row to show when there are no rows: one cell across the table’s colspan columns, saying “No results.” (the message sl.table.empty, which your messages.properties can reword). Content of its own replaces the text.

Nothing found
Thymeleaf source
<sl:table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr th:each="person : ${people}">
<td th:text="${person.name}">Ada Lovelace</td>
<td th:text="${person.email}">ada@example.com</td>
<td th:text="${person.role}">Owner</td>
</tr>
<sl:table-empty colspan="3" th:if="${people.empty}"/>
</tbody>
</sl:table>
Rendered HTML
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr class="table-empty">
<td colspan="3">No results.</td>
</tr>
</tbody>
</table>
</div>
<sl:table-empty colspan="3" th:if="${people.empty}">Nobody matches your search.</sl:table-empty>

Put a dropdown menu in the last cell, as shadcn’s data table does. Give each menu an id built from its row, so every trigger has a stable id of its own (people-12-actions-trigger), and name the trigger after the row: a screen reader user tabbing through the table hears “Actions for Ada Lovelace, menu button”. The header cell above the menus needs a name too, read out with each cell: <span class="sl-sr-only">Actions</span>.

Actions per rowA menu in the last cell of each row, named after its row. In an application its items post or link (see Tables with htmx); here they do nothing.
Thymeleaf source
<sl:table>
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th data-align="end"><span class="sl-sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr th:each="person : ${people}">
<td th:text="${person.name}">Ada Lovelace</td>
<td><sl:badge variant="secondary" th:text="${person.role.label}">Owner</sl:badge></td>
<td data-align="end">
<!--/* A role="menu" holds menu items only, so the form goes around the menu. */-->
<form method="post" th:action="${selfUrl}">
<input type="hidden" name="person" th:value="${person.id}">
<sl:dropdown-menu th:id="|people-${person.id}-actions|">
<sl:dropdown-menu-trigger variant="ghost" size="icon-sm"
th:aria-label="|Actions for ${person.name}|">
<sl:icon name="ellipsis"/>
</sl:dropdown-menu-trigger>
<sl:dropdown-menu-content align="end">
<sl:dropdown-menu-item as="a" th:href="@{/people/{id}(id=${person.id})}">
<sl:icon name="eye"/>View
</sl:dropdown-menu-item>
<sl:dropdown-menu-separator/>
<sl:dropdown-menu-label>Change role</sl:dropdown-menu-label>
<sl:dropdown-menu-radio-group aria-label="Change role">
<sl:dropdown-menu-radio-item th:each="role : ${roles}" type="submit" name="role"
th:value="${role}" th:checked="${person.role == role}">
[[${role.label}]]
</sl:dropdown-menu-radio-item>
</sl:dropdown-menu-radio-group>
<sl:dropdown-menu-separator/>
<sl:dropdown-menu-item as="a" variant="destructive"
th:href="@{/people/{id}/delete(id=${person.id})}">
<sl:icon name="trash-2"/>Delete
</sl:dropdown-menu-item>
</sl:dropdown-menu-content>
</sl:dropdown-menu>
</form>
</td>
</tr>
</tbody>
</sl:table>
Rendered HTML
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th data-align="end">
<span class="sl-sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ada Lovelace</td>
<td>
<span class="badge" data-variant="secondary">Owner</span>
</td>
<td data-align="end">
<button class="button dropdown-menu-trigger" type="button" data-variant="ghost" data-size="icon-sm" id="row-actions-1-trigger" popovertarget="row-actions-1" aria-haspopup="menu" aria-label="Actions for Ada Lovelace">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>
</button>
<div class="dropdown-menu-content" id="row-actions-1" role="menu" popover tabindex="-1" x-data="slDropdownMenu" aria-labelledby="row-actions-1-trigger" data-align="end">
<a class="dropdown-menu-item" role="menuitem" href="#row-actions">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
View
</a>
<div class="dropdown-menu-separator" role="separator"></div>
<div class="dropdown-menu-label">Change role</div>
<div class="dropdown-menu-radio-group" role="group" aria-label="Change role">
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="true" name="role" value="OWNER">
Owner
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="ADMIN">
Admin
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="MEMBER">
Member
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="GUEST">
Guest
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
<div class="dropdown-menu-separator" role="separator"></div>
<button type="button" class="dropdown-menu-item" role="menuitem" data-variant="destructive">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete
</button>
</div>
</td>
</tr>
<tr>
<td>Alan Turing</td>
<td>
<span class="badge" data-variant="outline">Member</span>
</td>
<td data-align="end">
<button class="button dropdown-menu-trigger" type="button" data-variant="ghost" data-size="icon-sm" id="row-actions-2-trigger" popovertarget="row-actions-2" aria-haspopup="menu" aria-label="Actions for Alan Turing">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>
</button>
<div class="dropdown-menu-content" id="row-actions-2" role="menu" popover tabindex="-1" x-data="slDropdownMenu" aria-labelledby="row-actions-2-trigger" data-align="end">
<a class="dropdown-menu-item" role="menuitem" href="#row-actions">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
View
</a>
<div class="dropdown-menu-separator" role="separator"></div>
<div class="dropdown-menu-label">Change role</div>
<div class="dropdown-menu-radio-group" role="group" aria-label="Change role">
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="OWNER">
Owner
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="ADMIN">
Admin
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="true" name="role" value="MEMBER">
Member
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="GUEST">
Guest
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
<div class="dropdown-menu-separator" role="separator"></div>
<button type="button" class="dropdown-menu-item" role="menuitem" data-variant="destructive">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete
</button>
</div>
</td>
</tr>
<tr>
<td>Grace Hopper</td>
<td>
<span class="badge" data-variant="secondary">Admin</span>
</td>
<td data-align="end">
<button class="button dropdown-menu-trigger" type="button" data-variant="ghost" data-size="icon-sm" id="row-actions-3-trigger" popovertarget="row-actions-3" aria-haspopup="menu" aria-label="Actions for Grace Hopper">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>
</button>
<div class="dropdown-menu-content" id="row-actions-3" role="menu" popover tabindex="-1" x-data="slDropdownMenu" aria-labelledby="row-actions-3-trigger" data-align="end">
<a class="dropdown-menu-item" role="menuitem" href="#row-actions">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
View
</a>
<div class="dropdown-menu-separator" role="separator"></div>
<div class="dropdown-menu-label">Change role</div>
<div class="dropdown-menu-radio-group" role="group" aria-label="Change role">
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="OWNER">
Owner
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="true" name="role" value="ADMIN">
Admin
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="MEMBER">
Member
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
<button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" name="role" value="GUEST">
Guest
<span class="dropdown-menu-item-indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
<div class="dropdown-menu-separator" role="separator"></div>
<button type="button" class="dropdown-menu-item" role="menuitem" data-variant="destructive">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>

An item that changes something is a submit button of a post form. A role="menu" may only hold menu items, so the form goes around the menu: a hidden input names the row, and each item sends its own name and value (role=ADMIN). View is a link; Delete is a link to a page that asks first. Tables with htmx turns the same menu into htmx requests, with an alert dialog for Delete, and keeps the focus on the row’s menu button.

A menu per row is markup per row: 50 rows with a badge and the menu above render in about 9 ms and 222 KB of HTML, 5 KB once compressed (see the cost).

For a bulk action (delete, change a role), give each row a checkbox of your own and put <sl:table-select-all/> in the header cell above them. Select-all checks or unchecks every row on the page, and is checked when they all are, indeterminate (a minus) when some are. <sl:table-selection-count> says how many are selected; place it where it fits, e.g. at the start of the footer, as shadcn’s data table does.

Selecting rowsThe rows are plain checkboxes named ids; one is selected, so select-all starts indeterminate.
Thymeleaf source
<sl:table id="people-selection">
<thead>
<tr>
<th><sl:table-select-all/></th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr th:each="person : ${people}">
<td><sl:checkbox name="ids" th:value="${person.id}"
th:aria-label="#{sl.table.select-row(${person.name})}"/></td>
<td th:text="${person.name}">Ada Lovelace</td>
<td th:text="${person.email}">ada@example.com</td>
</tr>
</tbody>
</sl:table>
<sl:table-selection-count for="people-selection"/>
Rendered HTML
<div class="table-container">
<table class="table" id="people-selection">
<thead>
<tr>
<th>
<span class="checkbox-wrapper table-select-all" x-data="slTableSelection" x-cloak data-format="{0} of {1} selected">
<input type="checkbox" class="checkbox" aria-label="Select all rows on this page">
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M5 12h14"></path>
</svg>
<span class="sl-sr-only" role="status"></span>
</span>
</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="checkbox-wrapper">
<input type="checkbox" class="checkbox" name="ids" value="1" aria-label="Select Ada Lovelace" checked>
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M5 12h14"></path>
</svg>
</span>
</td>
<td>Ada Lovelace</td>
<td>ada@example.com</td>
</tr>
<tr>
<td>
<span class="checkbox-wrapper">
<input type="checkbox" class="checkbox" name="ids" value="2" aria-label="Select Alan Turing">
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M5 12h14"></path>
</svg>
</span>
</td>
<td>Alan Turing</td>
<td>alan@example.com</td>
</tr>
<tr>
<td>
<span class="checkbox-wrapper">
<input type="checkbox" class="checkbox" name="ids" value="3" aria-label="Select Grace Hopper">
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="icon checkbox-indeterminate-indicator" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M5 12h14"></path>
</svg>
</span>
</td>
<td>Grace Hopper</td>
<td>grace@example.com</td>
</tr>
</tbody>
</table>
</div>
<span class="table-selection-count" x-data="slTableSelectionCount" x-cloak data-table="people-selection" data-format="{0} of {1} selected"></span>
<form id="people-bulk" method="post" th:action="${selfUrl}">
<sl:table id="people-table" th:page="${people}">
<thead>
<tr>
<th><sl:table-select-all/></th>
<sl:table-head sort="name">Name</sl:table-head>
<sl:table-head sort="email">Email</sl:table-head>
</tr>
</thead>
<tbody>
<tr th:each="person : ${people}">
<td><sl:checkbox name="ids" th:value="${person.id}"
th:aria-label="#{sl.table.select-row(${person.name})}"/></td>
<td th:text="${person.name}">Ada Lovelace</td>
<td th:text="${person.email}">ada@example.com</td>
</tr>
</tbody>
</sl:table>
<sl:button type="submit" variant="destructive">Delete selected</sl:button>
</form>
<div class="table-footer">
<sl:table-selection-count for="people-table"/>
<sl:pagination th:page="${people}"/>
</div>
  • The rows are yours. Plain sl:checkboxes (name="ids", the row’s id as value) submit with the form, also without JavaScript; the controller gets @RequestParam List<Long> ids. The table can sit inside the form, or the checkboxes can name a form elsewhere with form="...".

  • Which checkboxes are rows. Select-all works on the checkboxes in the body cells of the table it is in (not of a table nested in a cell), leaving out disabled ones, which would not be submitted. When the body has other checkboxes too, name="ids" on select-all narrows it to those.

  • The selected look comes from CSS: a row whose first cell holds a checked checkbox gets the muted background (shadcn’s data-state="selected"), with or without JavaScript. Put the selection in the first column.

  • Names. Every row checkbox needs an accessible name, telling the rows apart. aria-label with the message sl.table.select-row (Select {0}) as above, or point at the cell that names the row:

    <td><sl:checkbox name="ids" th:value="${person.id}"
    th:aria-labelledby="|person-${person.id}-name|"/></td>
    <td th:id="|person-${person.id}-name|" th:text="${person.name}">Ada Lovelace</td>

    Select-all is named “Select all rows on this page” (sl.table.select-all); an aria-label of yours replaces it.

  • The count reads “2 of 20 selected” (sl.table.selection.count; keep {0} and {1} as they are, the browser fills them in). Its table prop is the table’s id, which sl:table puts on the table. It is not a live region: a row’s checkbox announces its own state, so the count is only announced after a click on select-all, which changes rows the user did not touch.

Select-all and the count need Alpine, so they keep their place but stay hidden until it starts (x-cloak). Without JavaScript the user checks the rows one by one, and the form submits them as before.

Post the form to the page’s own URL, query string included, so the action knows the search, order and page to come back to. The controller below puts that URL in the model; after the action it redirects back there with a toast (post/redirect/get):

@PostMapping("/people")
public String delete(@RequestParam(name = "ids", required = false) List<Long> ids,
HttpServletRequest request, RedirectAttributes redirectAttributes) {
repository.deleteAllByIdInBatch(ids == null ? List.of() : ids);
redirectAttributes.addFlashAttribute("toasts", List.of(Toast.success("People deleted")));
String query = request.getQueryString();
return "redirect:/people" + (query == null ? "" : "?" + query);
}

Ask before a destructive action with an alert dialog inside the form: its destructive button is a submit button. With htmx, answer with the table alone; Tables with htmx shows how, and sample-01’s /people-multiselect page does both.

Select-all selects the rows on the page, and a sort, another page or a new search (with htmx too) starts with nothing selected: the selection lives in the checkboxes, and those are replaced. Keeping it across pages is the server’s business. Two ways:

  • Keep the ids on the server, e.g. in the session: each page posts its selection (or a checkbox posts on change with htmx), and the page renders checked on the rows that were selected before. Select-all and the count then start from those.
  • Select everything that matches instead of ids: after select-all, offer “Select all 270 people”, which posts the search itself (q) for the action to apply to every match.

Sample-01 keeps the selection per page.

<sl:table-select-all> renders x-data="slTableSelection" and <sl:table-selection-count> x-data="slTableSelectionCount". They need no Alpine plugin. They follow the rows’ change events (Space or a click), a form reset, and rows being added or replaced (an htmx swap). Changing the rows from select-all fires input and change on every row it changes, as a click would.

Event On Detail
sl-table-selection-change the table, bubbling {selected, total}, after every change, and when select-all starts

A bulk action can listen to it, for instance to show how many people it would delete:

<div x-data="{selected: 0}" @sl-table-selection-change.document="selected = $event.detail.selected">...</div>

A checkbox of your own can be indeterminate too: set its indeterminate property, or render <sl:checkbox indeterminate>.

shadcn’s data table runs TanStack Table in the browser. These parts of it are not in Shadleaf: sorting and filtering in the browser, column visibility, resizing, reordering and pinning, drag handles, expandable rows, editable cells, virtual scrolling, selecting a range of rows with Shift and a click, and a selection that lasts across pages (see above).

AttributeValuesDefaultDescription
th:pageobject—The Spring Data Page or Slice the table shows. Its Sort tells every sl:table-head inside how the rows are sorted now. Without it, the headers read the order from the request's sort parameter.
qualifierstring—The @Qualifier of the controller's Pageable, for a second table on the page: its sort links use members_sort instead of sort.

Every prop can also be set from an expression: th:page="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
sortstring—The property to sort by, as the controller's Sort names it (name, address.city). Only the first key of the current order is shown.
alignstart center endstartWhere the label sits in the cell. Put the same data-align on the column's tds: end for amounts.

Every prop can also be set from an expression: th:sort="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
colspannumberrequiredThe number of columns the cell spans: all of the table's.

Every prop can also be set from an expression: th:colspan="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
namestring—The name of the row checkboxes, when the body has checkboxes of another kind too. Without it, every checkbox in a body cell is a row.

Every prop can also be set from an expression: th:name="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
forstringrequiredThe id of the table whose rows it counts (sl:table puts its id on the table).

Every prop can also be set from an expression: th:for="${...}". Any other attribute passes through to the rendered element, and class is appended.