Skip to content

List

In review
Experimental: row zebra, not ratified

An opt-in zebra wash tones alternating rows (even rows on-card @ 4%, odd rows plain) on the list container: theme-following, default off, resting surface only (beneath hairlines / selection / hover). Experimental: under evaluation, not canon.

Live · web
Density

Grouped card: sections carry structure, no inset hairlines

Zebra (experimental): opt-in alternating row tone

Full-bleed (plain): no card, edge-to-edge rules

Selectable · single · ↑/↓ to move, one cyan indicator

nockerl-design · docs siteStreaming · 2 tools running
api-server · gateway refactorIdle · last active 12m ago
credential-store · allowlist auditApproval required
dueydo · failed deployBuild exited 1

Selectable · multi · leading checkboxes

ListDemo.tsx
ListItemDemo.tsx
DividerDemo.tsx
ButtonDemo.tsx
tokens.css

Sticky section header: scroll the region

Loading (skeleton) & empty affordance

No sessions yetNew sessions will appear here.

Selected session r2 · 2 files checked · comfortable density. The container is live: change --color-divider and every hairline moves.

// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,
// not yet exported from @dizyx/nockerl-react; promotion is tracked.)
// `List` is the CONTAINER; it arranges `NockerlListItem` rows (see the List item page).
import { NockerlListItem } from '@dizyx/nockerl-react';
export function SessionList({ groups, activeId, onSelect, density }: SessionListProps) {
return (
// variant: 'card' = lifted, inset grouped card · 'plain' = full-bleed (the dense tree)
// selection: 'single' = listbox + arrow-key roving · 'multi' = leading checkboxes
<List variant="card" density={density} selection="single" aria-label="Sessions">
{groups.map((g) => (
<ListSection key={g.key} label={g.label} count={g.items.length} sticky>
{g.items.map((s, i) => (
<NockerlListItem
key={s.id}
primary={s.name}
secondary={s.statusLine}
status={s.status} // 'success' | 'warning' | 'error' | 'info'
value={s.timeAgo}
selected={s.id === activeId}
onSelect={() => onSelect(s.id)}
divider={i < g.items.length - 1} // inset hairline BETWEEN rows
/>
))}
</ListSection>
))}
</List>
);
}

List is the container; ListSection groups rows under a header; the row itself is ListItem. Selection, density, and the card-vs-plain surface are list-level concerns set on List.

PropTypeDefaultDescription
variant'card' | 'plain''card'Surface treatment. card = a lifted, inset grouped card (depth on the container; inset hairlines). plain = full-bleed, no card or shadow, edge-to-edge rules (the dense tree).
density'comfortable' | 'dense''comfortable'Row height + padding only, never the fill. dense tightens spacing and drops the supporting line.
selection'none' | 'single' | 'multi''none'List-level selection. single renders a listbox with arrow-key roving focus + the selected-row cyan edge + cyan ink + leading check (never a stripe, never a wash, Law 6); multi adds leading checkboxes.
children *ReactNodeListSection groups or bare ListItem rows.
label (ListSection) *stringSection header text, a muted uppercase label on the surface.
count (ListSection)numberOptional trailing count shown beside the section label.
sticky (ListSection)booleanfalsePin the header to the top of a scroll region; keeps the surface token under it so rows never bleed through.
divider (ListItem)booleantrueRender the hairline after the row, inset to the content column (clears the leading slot). Pass false on the last row of a group.
Cross-platform drift (list container vs list item)

This page documents the container that arranges rows; the individual row (leading mark, primary/secondary text, trailing slot, the selected-row look) is List item. The real divergences between the shipped platforms, tracked in the Review queue:

  • No list wrapper anywhere: every platform hand-assembles the container. Compose configures a raw LazyColumn per identity, Voice wraps a LazyVStack in a card inline. A List / NockerlList with variant + selection + density (as the web API shows) is the open extraction item, so the container is consumed, not re-derived.
  • Sticky headers: the web demo and the SwiftUI-native List support sticky section headers, and Compose has stickyHeader, but no shipped screen uses it yet; section headers currently scroll with their rows. Canonizing the sticky behavior is open.
  • Grouped-card vs full-bleed: Voice always uses the inset grouped card; Compose’s dense tree (TaskTreeList) is full-bleed with no card. Both are valid (Decision 4), so the container must expose the choice (variant: 'card' | 'plain') rather than bake one in.
  • List-level selection: the one selection expression is a cyan edge bounding the run, cyan ink and a check, never a left stripe and never a wash. An earlier draft used a soft cyan wash; design law 6 (state by outline, not fill) superseded that, and the web container now carries the outline, drawing ONE edge around a contiguous run rather than a stack of them. The web container owns selection (listbox + arrow keys / multi checkboxes); Compose (today a per-row modifier background) still fills and lags here; Voice toggles expansion and has no persistent selection.