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.
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
.pairsthe block indt/ddmarkup: a definition list, because the left column names the right one.pairs divthe line. A wrapper is needed because adlcannot take adtand addinto one flex row without one. This is the rule the two markups sharedtthe label, in--text-mutedddthe value, in--text-primarywith tabular figures, pushed right byspace-betweenborder-bottomreads--line-divider: it separates rows inside one block rather than drawing an edge, so it is decorative and owes no contrast threshold. The last line is bare.pairs li .mutedthe only atom this component ever hosts, and only in the span markup: the value there is a muted line. This file positions it and never repaints it
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 |
| ||
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 |
| ||
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 |
| ||
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 |
| ||
| 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
- 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.
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.
- 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.
- 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
| Reads | Which token | Growing 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-body | 14px. 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-24 | the 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-4 | 3px, rounded up by the same review |
| the skeleton slots | literals | 84px 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.