Skip to content

List

A display list of rows: a leading column, a tinted media well, a title over a subtitle, a value at the end and a description that wraps under all of them. Every row is one grid whose text line always lands immediately before the growing column, so the separator, the wrapped line and the title start on the same edge whatever slots a row carries. It owns no selection and no navigation — the stylesheet draws the looks, your markup keeps the state.

  • Stable
  • React 19
  • Zero JS
  • Named-line row grid
  • 3 sizes
  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
  • Network Ethernet

Three rows on a card. The hairline between two of them starts where the text starts, not at the edge of the list.

Three exports, and only the first one is required reading: List is the <ul>, ListItem is a row, ListSection is a heading between groups.

  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
  • Network Ethernet
Code
<List variant="inset" aria-label="Settings">
<ListItem media={<WifiIcon />} title="Wi-Fi" subtitle="Casa" trailing="Connected" chevron />
<ListItem color="info" media={<BellIcon />} title="Bluetooth" subtitle="3 devices" chevron />
<ListItem title="Network" trailing="Ethernet" chevron />
</List>
<ul class="wayra-list" aria-label="Usage list" data-variant="inset" data-size="md" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="info">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Bluetooth</span>
<span class="wayra-list-subtitle">3 devices</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-body">
<span class="wayra-list-title">Network</span>
</span>
<span class="wayra-list-trailing">Ethernet<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>

title is the only required prop on a row. Every other slot is dropped from the DOM when you leave it out — no empty wrappers, so :has() in the stylesheet can key on what a row actually carries. Anything React renders as nothing drops a slot: null, undefined, false and an empty string, so a conditional such as trailing={unread > 0 && unread} never leaves an empty column behind. 0 renders, and stays. The same goes for children: when they render nothing, the row falls back to its title.

plain is a bare stack. inset puts the same rows on a flat card: a hairline border on an opaque surface, the box corner, and overflow: clip so the first and last row stay inside it. The border is what separates it from the page — no shadow, no blur.

  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
  • Network Ethernet
  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
  • Network Ethernet
Code
<List variant="inset">…</List>
<List variant="plain">…</List>
<ul class="wayra-list" aria-label="Inset list" data-variant="inset" data-size="md" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="info">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Bluetooth</span>
<span class="wayra-list-subtitle">3 devices</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-body">
<span class="wayra-list-title">Network</span>
</span>
<span class="wayra-list-trailing">Ethernet<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>
<ul class="wayra-list" aria-label="Plain list" data-variant="plain" data-size="md" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="info">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Bluetooth</span>
<span class="wayra-list-subtitle">3 devices</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-body">
<span class="wayra-list-title">Network</span>
</span>
<span class="wayra-list-trailing">Ethernet<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>

Three row heights — 40, 48 and 56px at the default unit — and everything inside a row is a fraction of the one it stands on: the padding, the media well, the column gap and the type size. Only --wayra-list-size changes between them.

  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
  • Wi-Fi Casa Connected
  • Bluetooth 3 devices
Code
<List size="sm">…</List>
<List size="md">…</List>
<List size="lg">…</List>
<ul class="wayra-list" aria-label="Small rows" data-variant="inset" data-size="sm" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="info">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Bluetooth</span>
<span class="wayra-list-subtitle">3 devices</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>
<ul class="wayra-list" aria-label="Medium rows" data-variant="inset" data-size="md" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="info">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Bluetooth</span>
<span class="wayra-list-subtitle">3 devices</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>
<ul class="wayra-list" aria-label="Large rows" data-variant="inset" data-size="lg" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="info">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Bluetooth</span>
<span class="wayra-list-subtitle">3 devices</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>
  • 1 Annual report Updated today 2.4 MB Closes the quarter with the figures from all three regions and a note appendix.
  • 2 Meeting notes No attachments yet; the summary lands when the review finishes.

Six slots, in DOM order: leading, media, body, trailing, chevron, description.

Code
<ListItem
leading="1"
media={<FolderIcon />}
title="Annual report"
subtitle="Updated today"
trailing="2.4 MB"
description="Closes the quarter with the figures from all three regions…"
chevron
/>
<ul class="wayra-list" aria-label="Documents" data-variant="inset" data-size="md" style="inline-size: 20rem">
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-leading">1</span>
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.75 4.25h4l1.5 2h7v7.5h-12.5z"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Annual report</span>
<span class="wayra-list-subtitle">Updated today</span>
</span>
<span class="wayra-list-trailing">2.4 MB<span class="wayra-list-chevron" aria-hidden="true"></span></span>
<span class="wayra-list-description">Closes the quarter with the figures from all three regions and a note appendix.</span>
</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-leading">2</span>
<span class="wayra-list-body">
<span class="wayra-list-title">Meeting notes</span>
</span>
<span class="wayra-list-description">No attachments yet; the summary lands when the review finishes.</span>
</li>
</ul>
Slot What it is Announced?
leading A rank, an ordinal, a time. Centred, muted, tabular-nums. Yes — a rank is data.
media The tinted well: an icon, an image, an Avatar. No, aria-hidden.
title The row’s main line. Truncates with an ellipsis. Yes.
subtitle Second line, muted. Truncates as well. Yes.
trailing Value, badge or a real control at the end of the row. Yes.
chevron A disclosure mark, two borders on an empty box. No, aria-hidden.
description A full line under the row. The one part that wraps. Yes.

The well is aria-hidden, so the row’s meaning has to be in the title. An icon-only row says nothing to a screen reader. mediaShape lands as data-media-shape, the default rounded included; rounded is also what the base rule already draws, so hand-written markup only needs the attribute for circle.

children replaces the title-and-subtitle block with a body of your own; the media and trailing slots stay where they are, and title is not rendered at all.

This is the part that is not obvious from the props. Every row is a grid with named lines, and the invariant is that the line called text sits immediately before the growing column, whatever leads it:

/* A rank or a well: one column before the text. */
.wayra-list-item {
grid-template-columns: [start] auto [text] minmax(0, 1fr) [tail] auto [end];
}
/* Neither: the text starts at the padding. */
.wayra-list-item:not(:has(> :is(.wayra-list-leading, .wayra-list-media))) {
grid-template-columns: [start text] minmax(0, 1fr) [tail] auto [end];
}
/* Both: two columns before it. */
.wayra-list-item:has(> .wayra-list-leading):has(> .wayra-list-media) {
grid-template-columns: [start] auto auto [text] minmax(0, 1fr) [tail] auto [end];
}

:has() re-points the template; nothing else moves. The body takes grid-column: text, and the description — the second row of the grid — takes grid-column: text / end, so it starts under the title and runs to the far edge however many columns came first. That is why the two descriptions in the example above line up with their own titles rather than with each other.

The separator follows the same line. It is not a border on the list: it is the ::before of the row below, which is why the first row has none and why a row can take its own inset away:

.wayra-list > li + li::before {
inset-inline: var(--_wayra-list-rule-start) 0;
}

The variable defaults to padding + well + gap. A row with no well, and a row with a leading column, both re-point it to the plain padding — a rank column is not where the text of a row starts. One variable moves, never a property, and a heading row cancels the rule under it outright, because a heading already separates its group from the one above.

ListSection is a heading row: small uppercase, muted, and an <li> like every other child, so the list stays a valid list.

  • Network
  • Wi-Fi Casa Connected
  • Ethernet
  • Alerts
  • Notifications 12
Code
<List variant="inset" aria-label="Settings">
<ListSection>Network</ListSection>
<ListItem media={<WifiIcon />} title="Wi-Fi" subtitle="Casa" trailing="Connected" chevron />
<ListItem media={<WifiIcon />} title="Ethernet" chevron />
<ListSection>Alerts</ListSection>
<ListItem color="warning" media={<BellIcon />} mediaShape="circle" title="Notifications" trailing="12" chevron />
</List>
<ul class="wayra-list" aria-label="Grouped settings" data-variant="inset" data-size="md" style="inline-size: 22rem">
<li class="wayra-list-section">Network</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">Connected<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 5.5a9 9 0 0 1 13 0M4 8.5a5.5 5.5 0 0 1 8 0"/><circle cx="8" cy="12" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Ethernet</span>
</span>
<span class="wayra-list-trailing"><span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
<li class="wayra-list-section">Alerts</li>
<li class="wayra-list-item" data-color="warning" data-media-shape="circle">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 10.5z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Notifications</span>
</span>
<span class="wayra-list-trailing">12<span class="wayra-list-chevron" aria-hidden="true"></span></span>
</li>
</ul>

It is presentational: it names a group visually and adds nothing to the accessibility tree. A group that has to be announced as a group is a nested <ul> carrying its own aria-label, not this.

The stylesheet ships the looks for selection and interactivity. It ships no behaviour at all:

The library draws You write
[aria-selected="true"] — the accent fill, the inverted well, both hairlines taken out The attribute, and the state behind it
data-interactive — a hover wash on rows, under @media (hover: hover); a selected row keeps its fill The click handler
.wayra-list-item:focus-visible — an inward focus outline tabindex, and where it moves
Nothing role="listbox" / role="option", and the keys
  • All files
  • Recents
  • Shared

Every ARIA attribute here is written by the consumer. The library contributed the fill.

Code
const [selected, setSelected] = useState(VIEWS[0]);
<List role="listbox" aria-label="Views" interactive>
{VIEWS.map((view) => (
<ListItem
key={view}
role="option"
aria-selected={view === selected}
tabIndex={view === selected ? 0 : -1}
media={<FolderIcon />}
title={view}
onClick={() => setSelected(view)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelected(view);
}
}}
/>
))}
</List>
<ul class="wayra-list" aria-label="Views" data-variant="inset" data-size="md" data-interactive="true" role="listbox" style="inline-size: 22rem">
<li class="wayra-list-item" data-color="primary" role="option" aria-selected="true" tabindex="0">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.75 4.25h4l1.5 2h7v7.5h-12.5z"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">All files</span>
</span>
</li>
<li class="wayra-list-item" data-color="primary" role="option" aria-selected="false" tabindex="-1">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.75 4.25h4l1.5 2h7v7.5h-12.5z"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Recents</span>
</span>
</li>
<li class="wayra-list-item" data-color="primary" role="option" aria-selected="false" tabindex="-1">
<span class="wayra-list-media" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.75 4.25h4l1.5 2h7v7.5h-12.5z"/></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Shared</span>
</span>
</li>
</ul>

This is deliberate. A display list that grew a selection widget by accident would ship half a listbox — the paint without the arrow keys, the type-ahead or the single tab stop the ARIA pattern requires. interactive says what the rows look like, not what they do.

The class plus the data-* attributes are public API, and the stylesheet is written against them — every preview on this page is hand-authored HTML with no React in the page:

<ul class="wayra-list" aria-label="Settings" data-variant="inset" data-size="md">
<li class="wayra-list-section">Network</li>
<li class="wayra-list-item" data-color="primary">
<span class="wayra-list-leading">1</span>
<span class="wayra-list-media" aria-hidden="true"><svg …></svg></span>
<span class="wayra-list-body">
<span class="wayra-list-title">Wi-Fi</span>
<span class="wayra-list-subtitle">Casa</span>
</span>
<span class="wayra-list-trailing">
Connected<span class="wayra-list-chevron" aria-hidden="true"></span>
</span>
<span class="wayra-list-description">Home network, 5 GHz band</span>
</li>
</ul>

A hand-written <li> with none of the part classes still gets the separator and position: relative: those rules are written against .wayra-list > li, not against the row class. Five custom properties are yours:

Property Default What it does
--wayra-list-size calc(var(--wayra-size-field) * 12) at md The row-height step every other measurement is a fraction of.
--wayra-list-pad calc(var(--wayra-list-size) * 0.32) Inline padding, and half of it block padding.
--wayra-list-media calc(var(--wayra-list-size) * 0.6) The media well’s box.
--wayra-list-gap calc(var(--wayra-list-size) * 0.24) Column gap between the slots.
--wayra-list-radius var(--wayra-radius-box) The corner of an inset list.

ListProps extends ComponentProps<"ul">, so id, style, ref, className, role, aria-label and every data-* land on the <ul>.

Prop Type Default Description
variant "plain" | "inset" "plain" inset puts the rows on a card.
size "sm" | "md" | "lg" "md" Row-height step: 40 / 48 / 56px at the default unit.
interactive boolean false Hover wash on rows. Emitted as data-interactive; wires no behaviour.
children ReactNode — The rows. Required. ListItem, ListSection or hand-written <li>s.
unstyled boolean false Drops the wayra-list class, keeps every attribute.

ListItemProps extends ComponentProps<"li"> minus color and title — both names are taken by slots below.

Prop Type Default Description
color WayraColor "primary" Tints the media well and paints its glyph. Emitted as data-color.
leading ReactNode — Column before the well: a rank, an ordinal, a time. Stays announced.
media ReactNode — Icon or image for the well. Decoration — the well is aria-hidden.
mediaShape "rounded" | "circle" "rounded" Emitted as data-media-shape, default included.
chevron boolean false Appends a disclosure chevron at the end of the trailing slot.
title ReactNode — The row’s main line. Required. Truncates rather than wrapping.
subtitle ReactNode — Second line, muted. Single line as well.
trailing ReactNode — Value, badge or control at the end of the row.
description ReactNode — A full line under the row, aligned with the title. Wraps.
children ReactNode — Replaces the title/subtitle block. title is then not rendered.
unstyled boolean false Drops wayra-list-item and every part class on this row.

ListSectionProps extends ComponentProps<"li">.

Prop Type Default Description
children ReactNode — The heading text. Required.
unstyled boolean false Drops the wayra-list-section class.

unstyled is per instance: a row inside an unstyled list keeps its own class unless it opts out too.

  • It is a real list. A <ul> whose children are all <li> — rows and headings alike — so a screen reader announces “list, 5 items” and counts them. That is the whole reason ListSection is an <li> and not an <h3>.
  • The meaning is in the title. The media well and the chevron are aria-hidden; the leading column is not, because a rank is data.
  • Name the list. A bare <ul> is announced as “list”. Pass aria-label, or point aria-labelledby at a real heading above it — two lists on one page need telling apart.
  • Interactivity is yours, all of it. If a row can be clicked it needs a role, a tab stop and keys. The APG checklist for a listbox is role="listbox" on the list, role="option" plus aria-selected on every row, one tab stop, and arrows that move it. interactive supplies none of that — it is a wash.
  • Focus rings inward. A row has neighbours on both edges, so it takes an outline at a negative offset rather than the library’s shared halo, which would sit on the row above. The colour follows the row’s own data-color.
  • Contrast. Titles are base-content on base-100: 17.9:1 light, 17.7:1 dark. Subtitles, trailing values and descriptions are the shared muted colour: 5.64:1 light, 6.15:1 dark. Both clear AA for body text. The 75% fade on a selected row is unmeasured and unused — see the caution above.
  • Forced colors. An inset list takes CanvasText for its border, and a selected row takes Highlight / HighlightText with forced-color-adjust: none. The chevron is two borders on an empty box, so Windows high contrast keeps it — it needs no font to be present.
  • Right to left. Every measurement is logical, so a row mirrors on its own. The chevron turns to point at the trailing edge under a dir="rtl" on the row or any ancestor, which the browser floor understands; :dir() (Chrome 120) only puts an LTR island back.
  • Reduced motion. The one transition in the file — the row’s background — goes to 0s through a variable. Nothing is disabled; nothing moves.

Do

  • All files
  • Recents

A listbox that really is one: role="option" on every row, one tab stop, and arrow keys behind it.

Don't

  • All files
  • Recents

A listbox whose children are not options and which no key can reach. The fill is only paint — it promises a widget that is not there.

Do

  • Network
  • Wi-Fi Connected

ListSection renders an

  • , so the list still owns nothing but list items.

  • Don't

      Network

    • Wi-Fi Connected

    An

    straight inside the
      . Invalid HTML, an axe list violation, and the item count a screen reader reads out is now wrong.