Skip to content
misoto22 design

Surfaces

DescriptionList

One record’s fields, as a real <dl> rather than a grid of divs.

Usage

When to reach for it

One record seen from the front — a detail page, a summary panel. Several records seen from above is a Table.
TSX
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

DescriptionList anatomy
ElementDescription
ListrequiredThe <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.
PairrequiredA <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.
Termrequireditem.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.
Descriptionrequireditem.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.
Hairlinedivided, 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

TSX
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.