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>Plain rows and cells
Section titled “Plain rows and cells”<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.
Alignment
Section titled “Alignment”<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.
Wide tables
Section titled “Wide tables”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.
Sorting on the server
Section titled “Sorting on the server”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:
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&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&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&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.
Parameter names
Section titled “Parameter names”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 tables on one page
Section titled “Two tables on one page”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.
Limit what can be sorted
Section titled “Limit what can be sorted”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:
@Controllerclass 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.
A stable order across pages
Section titled “A stable order across pages”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.
Sorting an enum column
Section titled “Sorting an enum column”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
AttributeConverterwrites the enum as a number, sosort=rolesorts by rank and the header needs nothing else. Sample-01 does this:public enum Role {OWNER(1), ADMIN(2), MEMBER(3), GUEST(4);...}@Converterclass 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
CASEexpression in a query of your own (order by case p.role when 'OWNER' then 1 when 'ADMIN' then 2 ... end), or withJpaSort.unsafe(...). The controller then maps theroleproperty of the requestedSortto that expression, and thePageit gets back carries the expression, notrole: build the page the template gets with the requestedPageable(new PageImpl<>(page.getContent(), pageable, page.getTotalElements())) so the header still shows the order.
Focus after an htmx swap
Section titled “Focus after an htmx swap”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.
Search and filters
Section titled “Search and filters”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.
Nothing found
Section titled “Nothing found”<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.
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>Actions per row
Section titled “Actions per row”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>.
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).
Selecting rows
Section titled “Selecting rows”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.
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 withform="...". -
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-labelwith the messagesl.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); anaria-labelof 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). Itstableprop is the table’sid, whichsl:tableputs on thetable. 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.
Without JavaScript
Section titled “Without JavaScript”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.
The bulk action
Section titled “The bulk action”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.
Selection across pages
Section titled “Selection across pages”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
checkedon 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.
JavaScript
Section titled “JavaScript”<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>.
Left out
Section titled “Left out”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).
Attributes
Section titled “Attributes”sl:table
Section titled “sl:table”| Attribute | Values | Default | Description |
|---|---|---|---|
th:page | object | — | 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. |
qualifier | string | — | 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.
sl:table-head
Section titled “sl:table-head”| Attribute | Values | Default | Description |
|---|---|---|---|
sort | string | — | 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. |
align | start center end | start | Where 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.
sl:table-empty
Section titled “sl:table-empty”| Attribute | Values | Default | Description |
|---|---|---|---|
colspan | number | required | The 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.
sl:table-select-all
Section titled “sl:table-select-all”| Attribute | Values | Default | Description |
|---|---|---|---|
name | string | — | 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.
sl:table-selection-count
Section titled “sl:table-selection-count”| Attribute | Values | Default | Description |
|---|---|---|---|
for | string | required | The 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.