Today
Bank account
Transfer sent
€ -200,00
Settlement account
Transfer received
€ 200,00
Vendor name
Payment sent
€ -200,00
Customer name
Payment received
€ 200,00
Yesterday
Vendor name
Pending payment
€ -200,00
Vendor name
Pending card payment
€ -200,00
Vendor name
Card payment
€ -200,00
Vendor name
Pending online payment
€ -200,00
16 Apr 2026
Bank account
Transfer sent
€ -200,00
Settlement account
Transfer received
€ 200,00
Vendor name
Denied online payment
€ -200,00
Vendor name
Online payment
€ -200,00
Overview
A list is a column of rows that share a structure. Every row reads as a tuple of three slots: a leading visual (avatar, icon, radio), a content column (title, optionally supporting text), and a trailing cell (a metric, a chevron, a toggle, or nothing). The slots are the contract; what fills them changes per surface.
When in doubt, reach for a list
Lists are the most under-used and most fitting layout for product data. Tables earn their place when columns need to be compared across rows; card grids earn theirs when each row has equal weight and rich media. Almost everything else is a list.
Anatomy
Five named parts inside the row, plus the section header that groups rows above it. The slots are optional in pairs — leading + trailing can both be absent — but the title is required. Without a title the row has nothing to read.
Today
Bank account
Transfer sent
€ -200,00
Leading slot
Avatar (40 px), icon (24 px), check, radio, thumbnail, or nothing. Anchors the row identity at a glance.
Title
Inter Tight Medium, 16 / 24. Required. The thing the row is about — a name, a label, an action.
Supporting text
Inter Tight Regular, 14 / 20, in text.secondary. One line. Status, timestamp, role, or other context.
Trailing slot
Currency, chevron, toggle, badge, or nothing. Right-aligned. Metric uses tabular numerals so columns stack.
Section header (optional)
Above the slot. Groups rows by date, status, or category. Inter Tight Semibold 14 / 20, text.secondary.
Content
Three content shapes cover almost every surface. Pick the lightest shape that carries the meaning — single-line where the metric and label work together, two-line where the row needs context, two-line + chevron when the row leads somewhere.
Single-line
Bank account
€ -200,00
Title only. Pairs with compact density. Use when the metric and label carry the meaning together.
Two-line
Bank account
Transfer sent
€ -200,00
The default. Title + one supporting line. Covers most product surfaces.
Two-line + chevron
Bank account
Transfer sent · Today, 14:32
Navigation list. The chevron tells the user the row leads somewhere. No trailing metric.
Density
Three densities tied to typical content. Pick deliberately — density is a design decision, not a styling one. The same component at different densities tells the user something different about the page.
compact · 32 px
Bank account
€ -200,00
Settlement account
€ 200,00
Title only, 32 px tall. Dense surfaces — recent activity dropdowns, tooltips.
default · 44 px
Bank account
Transfer sent
€ -200,00
Settlement account
Transfer received
€ 200,00
Title + supporting, 44 px tall. The standard product list.
comfortable · 56 px
Bank account
Transfer sent
€ -200,00
Settlement account
Transfer received
€ 200,00
Title + supporting, 56 px tall, larger leading. Marketing surfaces, generous detail pages.
States
Six visible states for interactive lists. Read-only lists (an activity feed, a receipt) sit at default and never react to hover. Promote a list to interactive only when the row leads somewhere or can be selected.
Default
Bank account
Transfer sent
€ -200,00
Hover
Bank account
Transfer sent
€ -200,00
Pressed
Bank account
Transfer sent
€ -200,00
Selected
Bank account
Transfer sent
€ -200,00
Focus
Bank account
Transfer sent
€ -200,00
Disabled
Bank account
Transfer sent
€ -200,00
Hover and pressed are background shifts only
No scale transform, no shadow, no glow. The state difference is read by the eye via tone, not motion. background.accent.neutral.subtler for hover, .subtle for pressed, accent.blurple.subtlest for selected.
Selection
Two selection patterns. Single-select uses a leading radio mark; multi-select uses a leading checkbox. The selected row inherits the discovery accent — blurple subtlest — so the eye can find it on a long list.
Single-select · radio
One row at a time. Use role="radiogroup".
Bank account
ending 4242
Card on file
ending 0102
Pay by transfer
settles in 1–2 days
Multi-select · checkbox
Any row can be on or off. Use role="group" and standard checkbox semantics.
Demo-ready
ready to share with leadership
Building
actively in development
Archived
kept for reference
Sections and dividers
A section is a labelled group of rows: a date header (Today, Yesterday, the absolute date for older), a status group (Demo-ready, Building, Archived), or a category. Sections are separated by a 1 px divider with 16 px padding above and below. Group headers sit at text.secondary so they read as metadata, not content.
Don't sticky every section header
Sticky headers are the right call for a long, scrolled feed where the user needs to know where they are (a transfers list across months). They're overkill for a settings panel or a short-bounded list. When in doubt, leave them static.
Empty and loading
Empty states teach the system: name what shows up here, and how the first row arrives. Loading states mirror the row's shape — a pulsing avatar, two lines of content, a trailing pill — so the layout never reflows when data lands.
Empty
No transfers yet.
Settled payments and outgoing transfers land here once your first payout clears.
Title in the row's normal weight; supporting copy explains what arrives. Don't illustrate.
Loading · skeleton
Skeleton mirrors the row's shape — leading circle, two lines of content, trailing pill. Same width buckets across rows.
Behavior
- Read-only by default. Most lists in the product (activity feeds, receipts, exported data) don't respond to hover. Promote a list to interactive only when the row navigates or can be selected.
- Whole row is the click target. When interactive, the entire row activates — not just the title. Minimum 44 px height for any interactive row to meet touch-target guidance.
- Trailing actions are separate. If a row needs both a primary navigation and a secondary action (kebab menu, delete), the secondary action is a button inside the trailing slot — it stops propagation so a click on the icon doesn't fire the row's navigation.
- Truncate, don't wrap. Title and supporting text both truncate with ellipsis. The row keeps a fixed height; long content falls off the right edge rather than expanding the row.
- Trailing metrics align. Currency, percentages, and counts use
tabular-nums slashed-zeroso the decimal point stacks down a column without column shimmer.
Accessibility
- Read-only list: use semantic
<ul>/<li>elements. Screen readers announce the count and let the user step through with arrow keys. - Navigation list: wrap each row in an
<a>or<button>. The row inherits keyboard focus and Enter activation natively. - Single-select:
role="radiogroup"on the list,role="radio"+aria-checkedon each row. Keyboard nav: arrow keys move and select, Tab leaves the group. - Multi-select: standard checkbox semantics. Each row carries its own checked state; Tab moves between rows.
- Section headers use
<h3>/<h4>at the page's appropriate level so screen readers can use them as landmarks. - Roving tabindex for long interactive lists — one row carries
tabindex=0, others-1. Arrow keys move the focus mark; Tab leaves the list. Skips the “tab through 300 rows” problem. - Status carried by colour pairs with copy— the failed avatar dot is decorative; the supporting text says “Denied online payment”. A screen-reader user without colour loses nothing.
Code
The List composes from List, ListSection, and ListItem primitives in @flatpay-dk/ui. Slots are plain children — pass any node into leading / trailing.
tsx
import { List, ListSection, ListItem, Avatar } from "@flatpay-dk/ui";
import { NorthEastOutlined, SouthWestOutlined } from "@mui/icons-material";
// Read-only feed
<List>
<ListSection label="Today">
<ListItem
leading={<Avatar><NorthEastOutlined /></Avatar>}
title="Bank account"
supporting="Transfer sent"
trailing="€ -200,00"
trailingTone="negative"
/>
<ListItem
leading={<Avatar><SouthWestOutlined /></Avatar>}
title="Settlement account"
supporting="Transfer received"
trailing="€ 200,00"
trailingTone="positive"
/>
</ListSection>
</List>
// Navigation list
<List>
<ListItem
as="a"
href="/transfers/abc"
leading={<Avatar>…</Avatar>}
title="Bank account"
supporting="Today, 14:32"
chevron
/>
</List>
// Single-select
<List role="radiogroup" aria-label="Payment method">
<ListItem
role="radio"
aria-checked={method === "bank"}
onClick={() => setMethod("bank")}
leading={<Radio checked={method === "bank"} />}
title="Bank account"
supporting="ending 4242"
/>
</List>Best practices
Lists are the workhorse layout. Small habits compound across screens.
Today
Bank account
€ -200,00
Settlement
€ 200,00
Do
Group by date or status when a feed spans more than ~10 rows. Sections are how the user finds what they came for.
Bank account
€ -200,00
Settlement
€ 200,00
Page 1 of 12 · ‹ ›
Don't
Don't paginate before grouping. A 50-row paginated list with no headers is harder to scan than 50 grouped rows on one page.
Bank account
Transfer sent
€ -200,00
Do
Title is the noun, supporting is the context. 'Bank account' / 'Transfer sent' reads at a glance.
Transfer sent to bank
14:32
€ -200,00
Don't
Don't bury the noun. 'Transfer sent to bank account' as the title means the eye reads four words to get to the same answer.
Bank account
€ -200,00
Settlement
€ 200,00
Vendor name
€ -200,00
Do
Negatives in orange, positives in green. Voided is line-through and muted — three colour roles, three meanings.
Bank account
Transfer sent
€ -200,00
Don't
Don't paint negatives in red. Outgoing money is normal product motion; red is reserved for errors and destructive states.
Props
Two primitives ship: the List container and the ListItem row. Section headers are a thin wrapper component.
List
| Prop | Type | Default | Description |
|---|---|---|---|
| density | "compact" | "default" | "comfortable" | "default" | Sets the row height token for every ListItem inside. compact=32 px, default=44 px, comfortable=56 px. |
| interactive | boolean | false | When true, rows render hover/pressed states and become focusable. Use when each row navigates or selects. |
| role | "list" | "listbox" | "radiogroup" | "menu" | "group" | "list" | ARIA role for the wrapping element. Choose based on whether the list is read-only, selectable, or actionable. |
| children | ReactNode | — | ListSection or ListItem nodes. Sections render a label + slot; items render a row. |
ListItem
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | The row's primary text. Required. Inter Tight Medium 16 / 24, truncates with ellipsis. |
| supporting | string | — | Optional second line. Inter Tight Regular 14 / 20 in text.secondary. Status, timestamp, role, etc. |
| leading | ReactNode | — | Avatar, icon, radio, checkbox, thumbnail, or any 32–56 px element. Sits before the content column. |
| trailing | ReactNode | — | Currency, badge, toggle, count, or any right-aligned element. Use tabular-nums for metrics so columns stack. |
| trailingTone | "primary" | "positive" | "negative" | "voided" | "secondary" | "primary" | Colour role for the trailing slot. positive=green.800, negative=orange.800, voided=line-through+muted. |
| chevron | boolean | false | Renders a right-chevron at the far end. Implies the row navigates somewhere. |
| as | "div" | "li" | "a" | "button" | "li" | Element rendered. Use "a" or "button" for interactive rows; the row's content becomes the click target. |
| ...rest | HTMLAttributes | — | All standard HTML attributes pass through (onClick, href, aria-*, etc.). |