Skip to main content

list — a collection of items, each its own shape

UiList renders a <ul> of items. Each item is a small record in its own right: a label (plain or a whole node), a leading icon, a muted description, per-item actions, and — the reason UiList exists — any UiNode as content. A list item can hold a form, a table, a chart or a nested stack.

Reach for UiList when the items are not a neat grid: activity feeds, search results, agent tool-call cards, anything where the rows differ from each other or carry rich bodies. When every row has the same columns and you want sorting, selection and stack-on-mobile, use table instead.

Live — items with an icon plus description and a per-item action, one item whose header is a rich labelNode, one collapsible item whose body is a whole stack, and a pagination footer.

Fields

FieldTypeMeaning
idStringNode id and DOM id of the wrapping <div class="sui-list">.
titleStringRendered as the <h2> in the list header. Omitted when absent.
iconStringLeading icon token in the header, before the title — lets a page's main list carry the same icon as its navigation entry. See icons.
headerExtraUiNodeNode rendered in the header row between title and actions — built for compact inline forms like a search field (the form chrome is stripped).
itemsList<UiList.Item>The rows. Defaults to an empty list. See below.
actionsList<UiAction>List-level buttons in the header, next to the title (Refresh, Add, …). Defaults to an empty list.
paginationUiList.PaginationOptional pager rendered under the items. null = no pager.
cssClassStringExtra CSS class added next to sui-list.

Item fields

UiList.Item is a plain nested class, not a UiNode — it has no type discriminator and no cssClass.

FieldTypeMeaning
idStringItem id. Becomes the <li> id and its data-id, so a single item can be patched on its own.
labelStringThe item headline as plain text. Kept as the accessible fallback even when labelNode is set.
labelNodeUiNodeRich header: rendered instead of the plain label text. Lets a row title carry structure — a name plus a status badge, an icon plus a menu-button.
iconStringLeading icon token before the plain label. Ignored when labelNode is set — a rich header owns its own layout. See icons.
descriptionStringMuted second line. Only rendered when content is absentcontent takes the same slot.
onClickUiTriggerFired when the item's label is clicked. null = a static, non-clickable label.
actionsList<UiAction>Per-item buttons, rendered in their own trailing cell. Defaults to an empty list.
contentUiNodeAny node, rendered as the item's body. Wins over description.
collapseSummaryStringWhen set, the whole item body is wrapped in a <details> disclosure with this text as the <summary>.
collapseOpenbooleanDefaults to false. true renders the disclosure open. Ignored when collapseClientControlled is set.
collapseSummaryIdStringPuts an id on the <summary> text so a patch can REPLACE just the summary (flip "running" to "done") without touching the body.
collapseClientControlledbooleanDefaults to false. true renders collapsed and tags the element data-sui-client-collapse, so the user's manual expand/collapse survives re-renders and streaming patches.

Pagination

UiList.Pagination is informational unless you give it a trigger template.

FieldTypeMeaning
pageintCurrent page, 1-based.
sizeintItems per page. The renderer derives the page count as ceil(total / size).
totallongTotal number of items across all pages.
pageTriggerUiTriggerTemplate fired when a page button is clicked. The renderer substitutes the literal {page} in the trigger's url with the target page number. null renders both buttons disabled — the pager becomes a read-out.

Building one

UiList.of("activity", "Recent activity")
.action(UiAction.secondary("l-refresh", "Refresh")
.onClick(UiTrigger.api("POST", "/activity/refresh")))

// icon + description + a per-item action
.item(UiList.Item.of("l1", "Order #1024 shipped")
.icon("success")
.description("web-frontend · 2 minutes ago")
.onClick(UiTrigger.go("/orders/1024"))
.action(UiAction.secondary("l1-show", "Show").icon("show")
.appearance(UiAction.Appearance.ICON)
.onClick(UiTrigger.go("/orders/1024"))))

// a rich header instead of a plain label
.item(UiList.Item.of("l3", "Import job")
.labelNode(UiStack.of(
UiIcon.of("l3-icon", "document"),
UiText.of("l3-title", "Import job"),
UiText.of("l3-badge", "· running"))
.direction(UiStack.Direction.HORIZONTAL).gap(8))
.description("3 of 8 files processed"))

// a collapsible item whose body is a whole node
.item(UiList.Item.of("l4", "Nightly build")
.collapsible("Nightly build — 3 warnings (click to expand)", false)
.content(UiText.of("l4-1", "Build passed with 3 lint warnings.")))

// a live card whose open state belongs to the user, not the server
.item(UiList.Item.of("l5", "Tool call")
.collapsibleClient("search_web — running…", "l5-sum")
.content(UiText.of("l5-body", "")))

.paginate(1, 4, 42, UiTrigger.api("GET", "/activity?page={page}"));

Notes

list or table? If every row has the same fields and the user wants to compare them column by column, it is a table — that is where sorting, filtering, row selection and stackOnMobile live. If the rows differ, or any row needs a body richer than a string, it is a UiList. Items are also individually addressable by id, which makes a list the better fit for feeds that grow by APPEND.

content replaces description, labelNode replaces icon. These are the two overrides worth memorising: the renderer picks content over description for the body slot, and labelNode over icon + label for the header slot. Setting both members of a pair is not an error — the loser is simply never drawn, though label is still kept as the plain-text fallback.

Client-controlled collapse is for live content. A server-driven open attribute fights the user every time a patch arrives: they collapse a card, the next re-render springs it open again. collapsibleClient(...) hands the state to the browser and tags the element so the morpher leaves it alone — which is exactly what streaming tool-call and sub-agent cards need. Pair it with collapseSummaryId so the summary can flip from "running…" to "done" via a targeted REPLACE while the body keeps streaming.

Pagination without a pageTrigger is decoration. The renderer emits both page buttons disabled when no template is given, which is a fine read-out but catches people out. Pass the trigger with a literal {page} in the URL and the renderer substitutes it per button — no per-page node building on the server.

Patch the item, not the list. Each <li> carries the item id, so a status change is one REPLACE on that item — not a re-render of the whole collection. That keeps scroll position, focus and every other item's disclosure state intact.

See also

  • table — same data, grid semantics.
  • detail — a single record instead of a collection.
  • action — list-level and per-item buttons.
  • text — the usual content of a simple item.
  • Triggers cookbook — item clicks, paging, streaming into an item.