Surfaces
DescriptionList
One record’s fields, as a real <dl> rather than a grid of divs.
Usage
When to reach for it
import { DescriptionList } from '@misoto22/design'Notes
A record's fields: label, value, label, value.
The single most repeated shape in any detail page, and the one most often hand-built out of a <div> grid — which loses the only thing the markup was carrying. A <dl> tells a screen reader that "Owner" names the thing beside it; a grid of divs tells it there are two columns of unrelated text, and the reader has to infer the pairing from reading order alone.
Reach for Table instead when there are several records. This is one record seen from the front; a table is many records seen from above.
Each pair is wrapped in a <div> — which the HTML specification allows inside a <dl> precisely so a pair can be laid out as a unit — so the hairline runs the full width of the row rather than stopping in the column gap.
An empty items renders null rather than an empty bordered box. A record with no fields is a state the page above should be handling with an EmptyState, and a hairline around nothing looks like a component that failed to load.
Anatomy
| Element | Description |
|---|---|
| Listrequired | The <dl>. It is the element that carries the pairing: a grid of divs looks identical and tells a screen reader there are two columns of unrelated text. |
| Pairrequired | A <div> around each dt/dd, which the HTML specification allows inside a <dl> precisely so a pair can be laid out as a unit. It is what the hairline is drawn on, so the rule crosses the whole row rather than stopping in the column gap. |
| Termrequired | item.term, rendered as <dt> at --ink-3-aa. In the row layout it holds a 12rem column at sm and above and stacks below it, because a 12rem label column on a phone leaves the value about eight characters wide. |
| Descriptionrequired | item.description, rendered as <dd> at --ink-2 with its browser margin reset. It takes a node, not a string, so a value can be a Badge, a link or a Timestamp. |
| Hairline | divided, on by default: a --rule under every pair but the last. Turn it off inside a Card, which already has an edge of its own. |
Best practices
Do
- Let it render nothing when items is empty. It returns null rather than an empty bordered box, so the page above is free to show an EmptyState instead of a hairline around no content.
- Put an element in description when the value is a state or a link — it is a <dd>, so a Badge, an anchor or a Timestamp belongs there and a string of text does not have to be faked into one.
- Pass id on each item when rows are added, removed or reordered. The index is the key without one, which is right for the fixed field list a record page renders and wrong for a list that changes shape.
Don’t
- Do not reach for it to show several records. Every dt would repeat down the page and a reader comparing two records has to hold both in their head — that is what a Table’s column headings exist to avoid.
- Do not use layout="row" inside a narrow sidebar. It only collapses on the sm breakpoint, which is the viewport, not the container — a 12rem label column inside a 20rem panel leaves nothing for the value. Use layout="stacked" there.
Examples
record fields
A record seen from the front. The markup is a real dl/dt/dd, which is what tells a screen reader that "Owner" names the value beside it — a grid of divs looks identical and says nothing. The value is a node, so a state is a Badge and a date is a Timestamp rather than two strings formatted by hand.
- Owner
- Henry Chen
- Region
- ap-southeast-2
- Status
- Deployed
- Last deploy
row and stacked
The same three fields under both layouts. row is the record-page shape — the labels line up down one edge and a reader scans them rather than reading them — and it collapses to one column under the sm breakpoint, because a 12rem label column on a phone leaves the value about eight characters wide. stacked keeps the value under its label at every width, which is the answer for a container that is narrow by design rather than by viewport: a card, a sidebar, a popover. Pick by the container, not by the number of fields.
- Runtime
- Node 24 on Cloudflare Workers
- Region
- ap-southeast-2
- Build
- pnpm --filter @misoto22/design build
- Runtime
- Node 24 on Cloudflare Workers
- Region
- ap-southeast-2
- Build
- pnpm --filter @misoto22/design build
a metadata rail
The shape it was built for: a detail page with the record on the left and its metadata in a rail beside it. The rail is stacked and undivided — it is already inside a bordered column, and a second set of hairlines inside one box is a grid pretending to be a table. Everything in the rail is a fact about the thing rather than part of it, which is the line to draw when deciding what belongs here: the title and the body are the record, the owner and the last deploy are what the page knows about it.
ui.misoto22.com
The documentation site for the design package. Statically exported, so every page is HTML on a CDN and the only JavaScript is the parts a reader touches.
Types
export type DescriptionListLayout = 'row' | 'stacked'Accessibility
- A real <dl>, <dt> and <dd>, which is what tells a screen reader that a label names the value beside it.
- Each pair is grouped in a <div>, which the specification permits inside a <dl> and which assistive technology reads through.
- An empty list renders null rather than an empty <dl>, so nothing announces a list with no items in it.