From 69c1114f7a8c32de33033e560f6cbeccdad80daf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Kuna?=
<1282324+codecalm@users.noreply.github.com>
Date: Thu, 8 Oct 2026 14:20:02 +0200
Subject: [PATCH] Add item slots, sizes, selectable and nested items to list
group (#3272)
---
.changeset/list-group-slots.md | 7 +
core/scss/_variables.scss | 8 +-
core/scss/ui/_lists.scss | 96 +++-
docs/content/ui/components/list-group.mdx | 229 +++++++-
preview/pages/lists.astro | 542 +++++++++++++++++-
shared/components/cards/UsersList.astro | 51 +-
.../components/cards/UsersListHeaders.astro | 20 +-
shared/ui/ListGroup.astro | 10 +-
shared/ui/ListGroupItem.astro | 45 +-
shared/ui/StatusDot.astro | 2 +-
10 files changed, 898 insertions(+), 112 deletions(-)
create mode 100644 .changeset/list-group-slots.md
diff --git a/.changeset/list-group-slots.md b/.changeset/list-group-slots.md
new file mode 100644
index 000000000..2ae4453ed
--- /dev/null
+++ b/.changeset/list-group-slots.md
@@ -0,0 +1,7 @@
+---
+"@tabler/core": minor
+"@tabler/preview": minor
+"@tabler/docs": patch
+---
+
+Added item slots, `.list-group-sm` and `.list-group-lg` sizes, selectable and nested items to the list group.
diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss
index ee24cc5fb..ba31bb158 100644
--- a/core/scss/_variables.scss
+++ b/core/scss/_variables.scss
@@ -1968,7 +1968,7 @@ $list-group-border-color: var(--border-color) !default;
$list-group-border-width: var(--border-width) !default;
$list-group-border-radius: var(--border-radius) !default;
-$list-group-item-padding-y: $card-cap-padding-y !default;
+$list-group-item-padding-y: 1rem !default;
$list-group-item-padding-x: $card-cap-padding-x !default;
$list-group-hover-bg: var(--hover-bg) !default;
@@ -1984,6 +1984,12 @@ $list-group-action-hover-color: var(--emphasis-color) !default;
$list-group-action-active-color: var(--body-color) !default;
$list-group-action-active-bg: var(--active-bg) !default;
$list-group-header-padding-y: 0.5rem !default;
+$list-group-item-gap: 1rem !default;
+$list-group-nested-indent: 2.25rem !default;
+$list-group-item-padding-y-sm: 0.5rem !default;
+$list-group-item-gap-sm: 0.75rem !default;
+$list-group-item-padding-y-lg: 1.5rem !default;
+$list-group-item-gap-lg: 1.25rem !default;
// Color variants: a tinted row, matched to the `bg-{color}-lt` utilities
$list-group-variant-border-alpha: 0.24 !default;
diff --git a/core/scss/ui/_lists.scss b/core/scss/ui/_lists.scss
index 5e0ab44db..e7ec6f6ba 100644
--- a/core/scss/ui/_lists.scss
+++ b/core/scss/ui/_lists.scss
@@ -2,6 +2,8 @@
.list-group {
--list-group-header-padding-y: #{$list-group-header-padding-y};
+ --list-group-item-gap: #{$list-group-item-gap};
+ --list-group-nested-indent: #{$list-group-nested-indent};
margin-inline-start: 0;
margin-inline-end: 0;
@@ -35,10 +37,98 @@
}
}
-.list-group-item.active {
+// A checked `.list-group-selectable-check` marks its item like `.active`,
+// the same way `.table-selectable` marks a table row.
+.list-group-item.active,
+.list-group-selectable .list-group-item:has(.list-group-selectable-check:checked) {
background-color: var(--active-bg);
- border-inline-start-color: $component-active-bg;
- border-inline-start-width: $border-width-wide;
+ // An inset shadow instead of a wider border, so the content does not shift
+ box-shadow: inset $border-width-wide 0 0 $component-active-bg;
+}
+
+// Density
+.list-group-sm {
+ --list-group-item-padding-y: #{$list-group-item-padding-y-sm};
+ --list-group-item-gap: #{$list-group-item-gap-sm};
+}
+
+.list-group-lg {
+ --list-group-item-padding-y: #{$list-group-item-padding-y-lg};
+ --list-group-item-gap: #{$list-group-item-gap-lg};
+}
+
+// Item slots: media, content (title + description), meta and actions.
+// The item becomes a flex row only when it holds a content slot, so items
+// with plain text or a grid row inside keep their layout.
+.list-group-item:has(> .list-group-item-content) {
+ display: flex;
+ gap: var(--list-group-item-gap);
+ align-items: center;
+}
+
+.list-group-item-media,
+.list-group-item-meta,
+.list-group-item-actions {
+ flex: none;
+}
+
+.list-group-item-media {
+ display: flex;
+ align-items: center;
+}
+
+.list-group-item-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.list-group-item-title,
+.list-group-item-description {
+ display: block;
+ @include text-truncate();
+}
+
+.list-group-item-title {
+ color: inherit;
+}
+
+.list-group-item-description,
+.list-group-item-meta {
+ color: var(--secondary);
+}
+
+// Nested items: a block of indented items under a parent item, usually a
+// `.collapse` toggled by that parent. It draws the side and bottom borders its
+// borderless items leave out.
+.list-group-nested {
+ border: var(--list-group-border-width) solid var(--list-group-border-color);
+ border-top-width: 0;
+
+ > .list-group-item {
+ padding-inline-start: calc(var(--list-group-item-padding-x) + var(--list-group-nested-indent));
+ border-width: 0;
+ }
+
+ + .list-group-item {
+ border-top-width: 0;
+ }
+
+ .list-group-flush > & {
+ border-inline-width: 0;
+
+ &:last-child {
+ border-bottom-width: 0;
+ }
+ }
+}
+
+// Chevron of a parent item, turned over while its nested items are open
+.list-group-item-toggle {
+ @include transition(transform $transition-time);
+
+ [aria-expanded='true'] & {
+ transform: rotate(180deg);
+ }
}
.list-bordered {
diff --git a/docs/content/ui/components/list-group.mdx b/docs/content/ui/components/list-group.mdx
index 22c47d6ca..0459a3c08 100644
--- a/docs/content/ui/components/list-group.mdx
+++ b/docs/content/ui/components/list-group.mdx
@@ -13,8 +13,29 @@ classnames:
desc: One entry
- class: list-group-header
desc: Section label between entries
+ - class: list-group-item-media
+ desc: Slot for an icon, an avatar or an input on the leading edge of an entry
+ - class: list-group-item-content
+ desc: Main slot of an entry; its presence turns the entry into a row of slots
+ - class: list-group-item-title
+ desc: First line of the content, cut with an ellipsis
+ - class: list-group-item-description
+ desc: Second line of the content, in the secondary color
+ - class: list-group-item-meta
+ desc: Slot for a time, a count or a badge after the content
- class: list-group-item-actions
desc: Slot for controls on the trailing edge of an entry
+ - class: list-group-selectable-check
+ desc: Checkbox or radio that marks its entry when checked
+ - class: list-group-nested
+ desc: Block of indented entries under a parent entry, usually a collapse
+ - class: list-group-item-toggle
+ desc: Chevron of a parent entry, turned over while its nested entries are open
+ size:
+ - class: list-group-sm
+ desc: Compact entries
+ - class: list-group-lg
+ desc: Roomy entries
style:
- class: list-group-flush
desc: Removes the outer border, to sit flush in a card
@@ -29,6 +50,8 @@ classnames:
desc: Highlights the entry under the cursor
- class: list-group-item-action
desc: Marks an entry as a link or a button
+ - class: list-group-selectable
+ desc: Gives an entry with a checked input the active look
direction:
- class: list-group-horizontal
desc: Lays the entries out in a row; -{breakpoint} only above that width
@@ -61,6 +84,113 @@ The container can be any element. Use `ul` with `li` items for a real list, or `
+## Item layout
+
+An item can hold more than one line of text. Put a `list-group-item-content` element in the item and it becomes a row of slots:
+
+- `list-group-item-media` - an icon, an avatar or an input before the content,
+- `list-group-item-content` - the main part, with `list-group-item-title` and `list-group-item-description`,
+- `list-group-item-meta` - a time, a count or a badge after the content,
+- `list-group-item-actions` - buttons and other controls at the end.
+
+Only the content slot is required. The title and the description are cut with an ellipsis when they do not fit.
+
+
+
+
+
+
+
+
+
Paweł Kuna
+
UI Designer
+
+
2 h
+
+
+
+
+
+
+
+
+
+
Alice Marlin
+
Frontend developer
+
+
3 d
+
+
+
+
+
+
+
+The item itself can be a link or a `label`. Use a `label` with a checkbox, a radio or a switch inside, so a click on the whole row changes the input.
+
+
+
+
+
+
+
+
+### Longer text
+
+The slots are centered and the description stays on one line. For longer text add the `align-items-start` utility to the item and `text-wrap` to the description.
+
+
+
+
+
+
+
+
+
Paweł Kuna
+
The new list layout works well on the dashboard. Can we use the same slots for the search results?
+
+
2 h
+
+
+
+
+## Sizes
+
+Add `list-group-sm` for compact items or `list-group-lg` for items with more room. The classes change the vertical padding of the items and the gap between the slots.
+
+
+
+
Small item
+
Small item
+
+
+
Default item
+
Default item
+
+
+
Large item
+
Large item
+
+
+
## Variants
### Flush
@@ -101,6 +231,57 @@ Mark the current item with `active`. Tabler shows it with a thicker border on th
+### Selectable items
+
+Add `list-group-selectable` to the container and `list-group-selectable-check` to the checkbox or radio in each item. A checked input gives its item the same look as `active`, with no JavaScript. Use a `label` as the item, so a click on the whole row changes the input.
+
+
+
+
+
+
+
+
+### Nested items
+
+Put the child items in a `list-group-nested` element right after their parent item. The nested items are indented and have no borders between them. Make the element a `collapse` and the parent item its toggle to open and close the group. Add `list-group-item-toggle` to the chevron, and it turns over while the group is open.
+
+
+
+
+
+The indent is the `--tblr-list-group-nested-indent` variable. Its default matches an icon in the media slot.
+
### Section header
Use `list-group-header` to split a long list into groups. The header is a small uppercase label with its own background.
@@ -122,20 +303,16 @@ Add `list-group-hoverable` to the container to highlight the row under the point
-
-
Change deprecated html tags
-
-
-
+
+
Change deprecated html tags
+
-
-
Fix spacing in the navbar
-
-
-
+
+
Fix spacing in the navbar
+
@@ -234,30 +411,22 @@ This pattern joins several variants: a flush list inside a card, hoverable rows,
diff --git a/preview/pages/lists.astro b/preview/pages/lists.astro
index 538288148..0a789c781 100644
--- a/preview/pages/lists.astro
+++ b/preview/pages/lists.astro
@@ -1,50 +1,540 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Card from '@ui/Card.astro'
+import CardBody from '@ui/CardBody.astro'
import CardHeader from '@ui/CardHeader.astro'
+import CardTitle from '@ui/CardTitle.astro'
+import CardSubtitle from '@ui/CardSubtitle.astro'
+import CardDropdown from '@ui/CardDropdown.astro'
import UsersList from '@shared/components/cards/UsersList.astro'
import UsersList2 from '@shared/components/cards/UsersList2.astro'
import UsersListHeaders from '@shared/components/cards/UsersListHeaders.astro'
import ListGroup from '@ui/ListGroup.astro'
import ListGroupItem from '@ui/ListGroupItem.astro'
+import Avatar from '@ui/Avatar.astro'
+import Badge from '@ui/Badge.astro'
+import Flag from '@ui/Flag.astro'
+import Icon from '@ui/Icon.astro'
+import Progress from '@ui/Progress.astro'
+import StatusDot from '@ui/StatusDot.astro'
import DocsLink from '@ui/DocsLink.astro'
+import Empty from '@ui/Empty.astro'
+import { people } from '@shared/lib/people'
+import type { FlagCountry } from '@shared/lib/tokens'
+import transactionsData from '@data/transactions.json'
+import notificationsData from '@data/notifications.json'
+
+const team = people.slice(0, 4)
+
+const folders = [
+ { icon: 'inbox', title: 'Inbox', description: 'Messages waiting for a reply', count: 12 },
+ { icon: 'send', title: 'Sent', description: 'Everything you have sent', count: 148 },
+ { icon: 'archive', title: 'Archive', description: 'Closed conversations', count: 2304 },
+ { icon: 'trash', title: 'Trash', description: 'Removed after 30 days', count: 7 },
+]
+
+const plans = [
+ { id: 'starter', title: 'Starter', description: 'Up to 3 projects', price: '$9' },
+ { id: 'team', title: 'Team', description: 'Unlimited projects, 10 members', price: '$29' },
+ { id: 'business', title: 'Business', description: 'Unlimited members and SSO', price: '$79' },
+]
+
+const channels = [
+ { id: 'email', icon: 'mail', title: 'Email', description: 'A summary every morning', checked: true },
+ { id: 'push', icon: 'device-mobile', title: 'Push', description: 'Mentions and direct messages', checked: true },
+ { id: 'slack', icon: 'brand-slack', title: 'Slack', description: 'Posts to the #updates channel', checked: false },
+]
+
+const comments = [
+ { text: 'The new list layout works well on the dashboard. Can we use the same slots for the search results?', time: '2 h' },
+ { text: 'Yes, the results only need a wrapped description and the avatar aligned to the top.', time: '1 h' },
+ { text: 'Done. I also added the nested items to the sidebar.', time: '12 min' },
+]
+
+const tree = [
+ { id: 'list-nested-projects', icon: 'folder', title: 'Projects', open: true, items: ['Website redesign', 'Mobile app', 'Design system'] },
+ { id: 'list-nested-teams', icon: 'users', title: 'Teams', open: false, items: ['Design', 'Engineering'] },
+]
+
+const sizes = [
+ { size: undefined, title: 'Default', subtitle: 'Without a size class the items use the default padding.' },
+ { size: 'sm', title: 'Small', subtitle: 'Add list-group-sm for a compact list.' },
+ { size: 'lg', title: 'Large', subtitle: 'Add list-group-lg for more room around each item.' },
+] as const
+
+const files = [
+ { icon: 'file-type-pdf', name: 'Annual report 2025.pdf', size: '2.4 MB' },
+ { icon: 'file-spreadsheet', name: 'Budget forecast.xlsx', size: '318 kB' },
+ { icon: 'photo', name: 'Office photos.zip', size: '48 MB' },
+ { icon: 'file-text', name: 'Meeting notes.md', size: '4 kB' },
+]
+
+const visits: { country: string; flag: FlagCountry; value: number }[] = [
+ { country: 'United States', flag: 'us', value: 64 },
+ { country: 'Germany', flag: 'de', value: 48 },
+ { country: 'Poland', flag: 'pl', value: 31 },
+ { country: 'Japan', flag: 'jp', value: 17 },
+]
+
+// Rows of transactions.json whose logo exists in static/.
+const transactionIds = ['3', '4', '9', '11']
+const transactions = (transactionsData as { id: string; counterparty: string; counterparty_logo: string; description: string; amount: number }[])
+ .filter((item) => transactionIds.includes(item.id))
+ .map((item) => ({
+ ...item,
+ amountLabel: `${item.amount < 0 ? '−' : '+'}${Math.abs(item.amount).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`,
+ }))
+
+const notifications = (notificationsData as { title: string; description: string; status: string }[]).slice(0, 4).map((item, index) => ({
+ ...item,
+ unread: item.status === 'unread',
+ time: `${(index + 1) * 2} h`,
+}))
---
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+ Text only
+ A plain list-group-item holds one line of text.
+
+
+ {folders.map((item) => {item.title})}
+
+
+
+
+
+
+
+ Title and description
+ Put list-group-item-title and list-group-item-description in list-group-item-content.
+
+
+ {folders.map((item) => )}
+
+
+
+
+
+
+
+ With icon
+ The list-group-item-media slot holds an icon before the content.
+
+
+ {
+ folders.map((item) => (
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ With avatar
+ The media slot takes an avatar the same way.
+
+
+ {
+ team.map((person) => (
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ With meta
+ Use list-group-item-meta for a time, a count or a badge at the end.
+
+
+ {
+ folders.map((item) => (
+
+ {item.count}
+
+ ))
+ }
+
+
+
+
+
+
+
+ All slots
+ Media, content, meta and list-group-item-actions in one item.
+
+
+ {
+ team.map((person, index) => (
+
+
+ {index === 0 ? : `${index * 3} d`}
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ Links
+ Add list-group-item-action to a link item. It supports active and disabled.
+
+
+ {
+ folders.map((item, index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ Actions on hover
+ In a list-group-hoverable list the actions show on hover. Add show to keep them visible.
+
+
+ {
+ team.map((person, index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ Buttons
+ The actions slot holds any control, for example a button.
+
+
+ {
+ team.map((person, index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ Checkboxes
+ In a list-group-selectable list a checked list-group-selectable-check marks its item.
+
+
+ {
+ team.map((person, index) => (
+
+
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ Radios
+ Radio inputs with one name make a single choice list. Use a label as the item.
+
+
+ {
+ plans.map((plan, index) => (
+
+
+ {plan.price}
+
+ ))
+ }
+
+
+
+
+
+
+
+ Switches
+ Put a switch in the actions slot to build a settings list.
+
+
+ {
+ channels.map((channel) => (
+
+
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ Wrapped description
+ Add align-items-start to the item and text-wrap to the description for longer text.
+
+
+ {
+ comments.map((comment, index) => (
+
+
+
{comment.text}
+ {comment.time}
+
+ ))
+ }
+
+
+
+
+
+
+
+ Nested items
+ Put list-group-nested on a collapse after the parent item. list-group-item-toggle turns the chevron.
+
+
+ {
+ tree.map((group) => (
+ <>
+
+
+
+
+
+
+
+ Drag handle
+ Add data-sortable to the list and put the handle in the media slot.
+
+
+ {
+ team.map((person) => (
+
+
+
+
+
+
+
+
+ ))
+ }
+
+
+
+
+ {
+ sizes.map((item) => (
+
-
-
- The current link item
- A second link item
- A third link item
- A fourth link item
- A disabled link item
+
+ {item.title}
+
+
+
+
+
+ {team.map((person) => (
+
+
+
+ ))}
+
+
+ Inside a card body
+ Without list-group-flush the list keeps its own border and rounded corners.
+
+ {
+ files.slice(0, 3).map((file) => (
+
+
+ {file.size}
+
+ ))
+ }
+
+
+
+