Pair list

A label and a value on one line, a hairline between the lines, the last one bare. One class name in the product, .pairs, which was .facts and .unlocks until step 6: after the spacing consolidation the two were byte identical at the line level. They differ in exactly one thing: the markup that carries the pair.

Molecule18 grey pages2 axesno states, and the reason is below

Anatomy

Two content slots per line and several lines in one unit, which makes it a molecule. Everything below is drawn by design/system/components/pair-list.css through the same entry point a product screen loads, so this page cannot drift from the screens.

Next charge
in 6 days, Aug 7
Billing cycle
Monthly
Charged to
Chase checking
Category
Music
Paid so far
$143.88 since Aug 2025

Variants and sizes

Two axes: markup, and content. They are independent and they answer different questions. Markup asks what kind of pair this is; content asks how long the right hand side is. The line rule itself has no variants at all, which is the point of the merge: one declaration serves both markups, because after the founder's review the 12px gap and the 16px gap fold onto one step and the 10px padding was already shared.

Markup

Semantic before visual. A definition list when the left column NAMES the right one. Two spans when the two halves are a feature and its explanation rather than a term and its definition

Where the ink is Where it stands
dt / dd dl.pairs label --text-muted
value --text-primary
18 grey pages, 8 coloured. The subscription detail and its four states, the connection screens, the cancel guide, sign-in
Billing cycle
Monthly
Charged to
Chase checking
span / span ul.pairs label inherits --text-primary
value --text-muted, via the atom
1 grey page and 1 coloured, both Upgrade, current plan: what being on Pro already gets you
  • History and trends3, 6 and 12 month views
  • Advanced alertsTrial ending, unusual, duplicate

The emphasis inverts along that axis, and it is deliberate rather than drift. In dt/dd the label is muted and the value is ink, because the value is the fact you came for: "Next charge" is the question, "in 6 days, Aug 7" is the answer. In the span markup it is the other way round, because the label is the feature and the value is the sentence explaining it: "History and trends" is the thing, "3, 6 and 12 month views" is the footnote. Neither ink is declared on the span half. The left one inherits, and the right one is a muted line that brings its own colour. A rule here setting it would be a host repainting an atom.

Content

Set by the LENGTH of the value, not by its kind. A value sits opposite its label; a sentence stacks under it, because opposite a label a sentence wraps into a ragged right-aligned column two words wide

Difference Where it stands
Values .pairs flex, right aligned 13 grey pages, 6 coloured
Seen
3 times, monthly since May
Last charge
Jul 12
Sentences .pairs.sentences block, left aligned
4px above the value
5 grey pages (sign-in, sign-in-sent, the two cancel guides, upgrade-payment-failed) and 1 coloured, the failed payment
What arrives
A link you tap. There is no code to copy out
How long it works
Once, and then it expires. You can send another at any time
Sentences in the span markup - Not forbidden, just unbuilt: the one screen using spans has short values on every line. If a Pro feature ever needs a sentence, this is the cell it lands in, and the rule it would need is the one .sentences already declares. A backlog line, not a prohibition

Loading is not an axis and it is not a state of this component. Both markups can hold skeleton bars instead of text, and the two widths that reserve the slots, 84px for a label and 110px for its value, live in this file because skeleton-bar.css hands them here by name: that file owns the bar, the host owns the slot. Two different widths on purpose, because a column of identical bars reads as a progress meter and loading is not progress.

Copied from the loading state exactly, including aria-hidden="true" on the list: a screen reader is told the page is loading by the state block above it, and reading out ten empty bars underneath would be worse than silence.

When to use it

A pair list is where the product answers the questions a person actually has about one thing, in the order they ask them. On a subscription's own screen that is five lines and no more: when the next charge is, how often, from which account, what kind of thing it is, and what it has cost so far. Nothing on that list is an opinion and nothing on it is an action, which is why the whole block is readable in about four seconds and why nothing in it is a target.

It is the plainest expression of the product's own rule about numbers: plain money language, and every number in context. "$143.88 since Aug 2025" rather than "$143.88". "in 6 days, Aug 7" rather than "2026-08-07", and days first because a person thinks in days and checks the date second. Those are decisions from voice/docs/microcopy.md and wireframes/docs/conventions.md, and this component's job is to put them where they can be scanned as a column.

The .sentences variant is what happens when the honest answer is not a value. On the failed payment screen the two questions are "what was charged" and "what is my plan now", and both answers are sentences: "Nothing. There is no payment to reverse", "Still Free, and everything free is still working". Squeezing those opposite a label would make them look like data. Stacked under it, they read as what they are.

The rule, and the anti-rule

Facts about one thing
Next charge
in 6 days, Aug 7
Charged to
Chase checking
Paid so far
$143.88 since Aug 2025

One subject, several attributes of it, right aligned so the answers form a column the eye can run down. The dividers separate lines of the same block, and the last line is bare so the list does not look like a table with a stray rule under it.

Use a subscription row instead
Spotify Premium · $11.99 · Aug 7

Several different things, each with a name and an amount, is a list of subscription rows inside a divided list, not a pair list. The difference is what the left column is: in a pair list it is a label, always the same kind of word; in a row it is the merchant's own name, and the whole row is one link with one accessible name. Setting a list of subscriptions as pairs would strip every one of them of its target.

A sentence gets the whole measure
What was charged
Nothing. There is no payment to reverse
Your plan
Still Free, and everything free is still working

When the answer is a sentence, .sentences drops the line out of flex and stacks the pair. Same component, same divider, same rhythm; the only thing that changes is that the answer gets the full width to be a sentence in.

Use a promise list instead
  • What Tendd can never doRead-only. Tendd cannot move your money.

A short heading over a sentence looks like the same shape and is a different component. A promise list is a claim the product is making about itself, marked by a rule down its left side, and it is read once before a decision. A pair list is a fact about the person's own data, divided by hairlines, and it is read every time they come back. The rule is the mark of a claim; the hairline is the mark of a record.

States

None, and it was checked rather than assumed. A pair list is a table of facts read once: a sweep of every .facts and every .unlocks on all 57 grey pages, and of every .pairs on all 57 coloured ones, returns zero anchors inside any of them, and no line is hoverable, focusable or pressable. Where a fact needs an action, the action is a sibling below the list, which is how the detail screen's "Edit the details" is written. Drawing four empty cells would document something the product cannot do.

The technical half

ReadsWhich tokenGrowing from
the label, dt/dd markup--text-muted--muted. The single secondary tier, 5.8:1 light and 6.4:1 dark
the value, dt/dd markup--text-primary--ink. 17.4:1 light, 15.0:1 dark
the divider--line-divider--hairline-soft. Decorative: nothing here has to be found by touch, so 1.23:1 is deliberate
the line's size--type-body14px. Was 13.5 on both names, folded by the founder's review of 2026-08-11
the gap, the padding, the block--space-16, --space-8, --space-24the 8px grid. The 12 and 16 of the two gaps folded onto one step, which is what made the two names one rule
the sentence offset--space-43px, rounded up by the same review
the skeleton slotsliterals84px and 110px. A width is not a spacing and the 8px grid does not govern it, which is how skeleton-bar.css writes its own widths too

Lives in design/system/components/pair-list.css. Stands on 19 grey pages, 22 lists, and 20 coloured ones, 24 lists. A sample of the coloured screens carrying it: Subscription Detail and its price change, payment failed, unrecognized and loading states, Upgrade, current plan (the only page carrying both markups), and the failed payment, which is the only coloured page carrying .sentences. The eighth arrived at stage 09: Alerts with nothing to show, where the pairs say what Tendd is watching for.

<dl class="pairs"> <div><dt>Next charge</dt><dd>in 6 days, Aug 7</dd></div> <div><dt>Billing cycle</dt><dd>Monthly</dd></div> </dl> <dl class="pairs sentences"> <div><dt>What was charged</dt><dd>Nothing. There is no payment to reverse</dd></div> </dl> <ul class="pairs"> <li><span>History and trends</span><span class="muted">3, 6 and 12 month views</span></li> </ul>

One rule came back to this file at the rename, and the element is what saved it. .app .pairs li .muted { text-align: right } sat in muted-line.css from the atom round, with a comment offering it back to whichever host claimed it, and it is in pair-list.css now: right aligning a child is the host doing its job, not the atom doing its own. It could not simply become .pairs .muted, because that reaches the dt/dd markup too, where the value half is already right aligned and the label half must not be. li is the discriminator the markup already carries, it selects the same 4 elements on upgrade-current-plan that .unlocks .p selected, and it cannot reach a dl.