Select
The control for a value that comes from a closed list. Four of them exist in the whole product, and both questions they ask, a billing frequency and a currency, are questions the product has to understand rather than merely store.
Anatomy
One zone and one value, which is what makes it an atom. Everything below is drawn by design/system/components/select.css through the same entry point a product screen loads, so this page cannot drift from the screens: there is no second copy of the CSS here to drift from.
.selectthe atom itself: a full width box with a 44px floor, a control radius and a one pixel edge. The same box as the Text input, by a decision the grey CSS had already made, and step 6 gave it the class in place of the bare.field selectselectorpadding-right8px against the input's 12px, and the only declaration that separates the two files. What sits in the difference is the arrow- the arrow ours, and it is the same chevron the nav row draws.
appearance: noneand a hand-built control are still refused; what the picker block does instead is take the browser's own base drawing and then replace the mark on it. Base appearance swaps Chrome's hairline chevron for a filled disclosure triangle, 9 by 8 against 10 by 5, and that triangle is the browser's mark in a control we had just taken over. The founder settled the principle on 2026-08-11 on a different component: the nav row's›went because punctuation doing an icon's job reads as a font fallback next to real marks. This is the identical 16 by 16 path, rotated a quarter turn, oncurrentColorat--text-muted, so the two marks in this product that mean "there is more this way" are one shape and change in one place. Inside@supportsonly: a browser without base-select keeps its own arrow, because we cannot reach it.
Variants and sizes
One form, and no axis at all. The registry records this component's axes as a dash, and the corpus agrees: a sweep of all 57 grey pages finds four selects, on four pages, and every one of them renders the same box. There is no emphasis axis, no size axis and no content axis, so there is no matrix to draw. A matrix with one row would say a component varies when it does not.
| Where it stands A select is used where the answer is one of a SHORT closed set the product already knows. Where the set is open, or long, the answer is a text input and a catalogue, which is why the preset search is not a select |
Options | The question it asks |
|---|---|---|
The one form .select |
14px / 44px 0 8px 0 16px |
4 grey pages, one select each: add-subscription with its empty and error states, and settings |
| multiple | - | Forbidden with a reason rather than missing. A multiple select is a list box a person has to know to ctrl-click, and this product is built for someone with low financial confidence who is already braced for the interface to be clever at them. Several choices at once are checkboxes in a Switch row |
| optgroup | - | Forbidden with a reason. Neither list is long enough to group: five frequencies and three currencies. A grouped list is a sign the question is two questions |
| a drawn dropdown | - | Refused, and the refusal is what made the styled picker possible. A custom listbox is script, focus management and a keyboard contract, and it would be the first component in the system that stops working when JavaScript does. appearance: base-select takes the other road: the browser keeps the keys, the roles and the phone's own sheet, and the list takes our surface. See States below for what is ours and what is not |
Billing frequency #sub-freq |
5 | Monthly, Every 4 weeks, Quarterly, Yearly, Custom. On Add a subscription and on its empty and error states, so three of the four occurrences are one question asked at three moments |
Currency #currency |
3 | US dollar, Euro, British pound. On Settings, and only there. D5 defers the EU, so two of the three are shipped against a market the product has not opened yet |
Two things vary and neither is a variant. The number of options is content: five and three draw the identical control, and a component whose only difference is what is inside it has one form. Invalid and disabled are states, and they live in the States block below with the token each one reads. Calling either a variant would put a matrix row in front of the reader that no decision stands behind.
There is no size axis, and that is the same rule the text input follows. The control's height is the 44px tap floor, and 44 is a WCAG minimum rather than a value this system chose, so it is not ours to make smaller in a tighter container. A select in a narrow column gets narrower and never shorter.
THIS SECTION WAS REBUILT ON 2026-08-11, and the reason is worth leaving on the page. It went missing in two steps, and only the second was the bulk pass. An earlier edit of mine replaced a run of text by index and swallowed the close of the callouts list, the end of the Anatomy section and the head of this one, which left this table's rows orphaned INSIDE Anatomy. The browser then closed .kit-wrap at the first unbalanced </div>, so five of the six sections fell out of the page measure and ran the full width of the window. The rows themselves were never lost and are back above, in their own words: the measured values, and the three forms this component refuses. The sweep that ran afterwards checked scroll, broken images, table spans and theme pairs on 256 views and passed every one, because none of those checks asked whether a page still had all five of its blocks. The founder found it by opening the page. The structural check now runs with the rest.
When to use it
A select asks for a value the product has to understand, not merely keep. Billing frequency decides what "a month" means for that subscription and therefore what the monthly total says, and the monthly total is the number the whole product is built around. Currency decides what every amount on every screen is printed in. Both would be unusable as free text: "monthly-ish" cannot be turned into a projection, and a product that rejects a person's typing on the manual path has made the manual path expensive, which is exactly what D2 forbids.
The option strings come from voice/docs/microcopy.md and are written the way a person would say them: "Monthly", "Every 4 weeks", "Quarterly", "Yearly", "Custom"; "US dollar ($)", "Euro", "British pound". Not "MONTHLY", not "P1M". The hint under the currency select says what changes when the answer changes: "Every amount in Tendd is shown in this currency."
The rule, and the anti-rule
Three values, all of them known to the product, and the answer changes how every amount in the app is printed. A closed list, asked once, on a screen a person visits rarely.
An open value belongs to the Text input. "Cerebro Cloud" is the real string on add-subscription-empty, the state that exists because a service is not in the preset catalogue of 400+. A select of 400+ services is a scroll, not a choice, and the product already answers that question better with a search field and preset tiles.
Five frequencies, one line, and the reading order stays the reading order. A radio group of five would take five rows of a form whose whole argument is that it is short.
Two values, and one of them is "no", is the Checkbox in a Switch row. It shows both states at once instead of hiding one behind a tap, it reads as a sentence a person agrees with, and it needs no submit. A select of Yes and No is one more tap for a fact the eye could already have had.
States
Six states, and they exist because the select is interactive, not because the template has cells to fill. Each one is a token override rather than a style of its own: no hex and no number appears inside :hover or [aria-invalid], so the dark theme costs three lines in tokens.css rather than forty edits across the components.
Open is one of the six now, and it was not before. Founder's decision, 2026-08-11: the option list stops looking like an operating system menu, a grey panel with a blue highlight, and takes the product's language. The mechanism is appearance: base-select behind an @supports guard, it is CSS only, and it retires the line this page used to carry, that the open list "is not a state and cannot be one".
Why the browser's picker and not a listbox of our own, and the accessibility argument comes first. Base appearance keeps the browser's keyboard contract, its ARIA roles, its type-ahead and the phone's native sheet, so none of that has to be re-implemented and none of it can be re-implemented wrong. A scripted listbox would also be the first component in this system that stops working when JavaScript does, in a product built for someone anxious about money who is steadied by a control that behaves like every other control on their phone.
What is ours and what is not, plainly. Ours: the list's surface, its edge and its corner, the height of an option and its padding, the fill under the pointer, the ink on the chosen one, the ring the keyboard leaves. Not ours: the arrow, which stays the browser's own drawing and takes a colour role and nothing else; where the list is placed and how it scrolls; every key that operates it. Measured in Chrome 151 on 2026-08-11, where CSS.supports('appearance', 'base-select') answers true, and Edge is the same engine. Safari and Firefox answer false today, and there nothing changes at all: the guard misses, the control is what it has always been, and the list is the operating system's, which is the better list on a phone anyway. If either ships it later this block starts applying with no edit.
The closed control does not move, and that was measured rather than trusted. At 360 and at 1280, in both themes, before and after: 44px tall, 0 8px 0 16px, a 1px --line-control edge, a 10px corner, 14px, --bg-surface, --text-primary. Two things inside it do move, and both are base appearance replacing Chrome's private interior with the one this file declares. The value text needs align-items: center or it lands 11px too high, and it starts 4px further left, because the native control adds a private 4px inset the system never asked for and 16px is what the system says. The arrow changes with it: Chrome's hairline chevron, 10 by 5 px, becomes base appearance's own triangle, 9 by 8 px, in the same corner of the same box, and --text-muted is what brings its weight back down to the hairline's.
The invalid state is authored, not extracted. A sweep of all 57 grey pages returns no aria-invalid, no required and no error class, and kit.css said so in the comment above the rule: "AUTHORED. A sweep of all 55 pages returns no aria-invalid, no required and no error class, so the kit would otherwise inherit an input that cannot be invalid. Clay, never red. The hook is the native attribute, so no page needs a new class." The state is real, it is in the system, and no screen uses it yet. It is waiting for the markup that stage 09 or the rollout will give it, and until then it moves zero pixels.
Rest, live
The second one is genuinely invalid and the third genuinely disabled, both by native attributes rather than by classes the product does not carry. Faking a state with .is-invalid would document something the product cannot do. Open is live here too, in a browser that has it: click the first control, or tab to it and press the down arrow, and the list that opens is the one described below, drawn by the same file the product loads.
The six, in both themes
The states are described, not photographed. Founder's decision, 2026-08-11: the snapshot strip went from every page in this kit, 120 images across 18 pages. They were framed as measurements rather than illustrations, and that framing was honest, but the bill came due all session: a value moves, and every picture of it is quietly stale until somebody re-shoots it. A stale picture is worse than no picture, because it looks checked. What is left is what a picture never carried anyway: the state, and the token it reads. The resting component is live and interactive above, so hover it, tab to it, and read the value off the real thing rather than off a photograph of it.
--line-control, --bg-surface, --text-primarycolor, so it inverts with the theme without a rule--line-control-hover, the edge alone--color-focus, 6.23:1 as a line--color-focus, 6.82:1. The ring is 2px at 2px offset and sits OUTSIDE the box, so focusing a select moves no neighbour--bg-surface on the list and --line-container on its edge, --radius-sm, an option 44px tall, --bg-hover under the pointer, --text-action and the browser's checkmark on the chosen one--bg-failure and --line-failure. Never red: the persona closes any app that shows her red--bg-recessed, --line-divider, --text-mutedRetake, and this is the whole recipe, because there is no node Playwright in this repo and a
command that does not run is worse than no command:
serve the repo, then for theme in (none, data-theme="dark" on <html>):
goto /design/kit/select.html
move the pointer away -> screenshot #live-rest -> screens/select-rest-<theme>.png
hover #shot-rest -> screenshot #live-rest -> screens/select-hover-<theme>.png
focus #shot-rest, press Tab, assert :focus-visible -> screenshot #live-rest
-> select-focus-<theme>.png
blur, screenshot #live-rest -> invalid and disabled are both already in frame,
so the same shot serves as
select-invalid-<theme>.png and
select-disabled-<theme>.png
click #shot-rest, hover the third option, screenshot the VIEWPORT around the
control (the picker is a popover and sits outside #live-rest's own box)
-> select-open-<theme>.png
The open shot is only shootable in a browser that has base-select: there the list is
in the page and the capture holds it. In Safari or Firefox the same click opens the
operating system's own window, which lands outside the page and photographs as nothing,
and that is the fallback rather than a broken shot.
The states are reached the way a person reaches them: a real pointer, a real Tab, a real
attribute. NO FILE COMES OUT OF THIS TODAY, and the recipe is kept for the day one is
wanted rather than as a promise. The strip was removed by the founder on 2026-08-11 and
the states above are described instead, so there is nothing here for step 9 to diff. The
earlier wording said step 9 checks these files for a byte shift; it was written before the
decision and outlived it, which is the same freeze it was warning about, in prose.
The technical half
| Reads | Which token | Growing from |
|---|---|---|
| its fill | --bg-surface | --paper |
| the chosen option | --text-primary | --ink |
| the edge that identifies it | --line-control | --edge |
| the edge under the pointer | --line-control-hover | --muted |
| the focus ring | --color-focus | --petrol |
| the invalid wash | --bg-failure | --clay-wash |
| the invalid edge, and its ring while invalid | --line-failure | --clay, and --clay-dark in the dark theme. 5.46:1 on the light surface and 7.40:1 on the dark, both over the 3:1 a line owes. It did not exist when this component was written: clay had a role as an ink and a role as a fill and none as a line, so the hole was reported rather than invented, and nothing moved when it was filled |
| disabled | --bg-recessed, --line-divider, --text-muted | three roles that already existed |
| corner and floor | --radius-sm, --tap | primitive, straight: geometry has nothing to override |
| the arrow | --text-muted, and nothing else | the browser still draws it. One colour role, no size and no shape: appearance: none and a hand-built arrow are still refused |
| the list's surface and edge | --bg-surface, --line-container | --paper, --hairline. The edge of a container rather than of a control, so the decorative hairline is the right role and the 3:1 one is not. No shadow: the Flat Paper Rule |
| the list's corner and inset | --radius-sm, --space-4 | the same corner the control has, so the two read as one object opening |
| an option | --tap, --space-8, --space-16, --radius-xs, --type-body, --text-primary | the browser's own base option is 24px tall with 7px of side padding, which is a mouse target. An option is a thing a finger lands on, so it takes the 44px floor the project holds everywhere |
| an option under the pointer | --bg-hover | --panel, the same fill the row, the tile and the outline button take |
| the chosen option | --text-action | --petrol, one of its four permitted jobs. NOT colour alone: the browser's ::checkmark stays, inherits that ink and keeps its box reserved on every option, so nothing shifts when the choice moves and WCAG 1.4.1 is answered by a mark rather than by a hue |
| the ring on an option | --color-focus | the same token and the same 2px as everywhere else. The offset is -2px and that is the one difference: the list scrolls, so a ring drawn outside the option is clipped at the first and last row |
Lives in design/system/components/select.css. Stands on 4 grey pages, one select each, and all four are coloured: Add a Subscription, its empty state, its error state, Settings.
Where the picker got to, and it is worth saying out loud. Those four coloured screens loaded kit/kit.css, the flat stage 07 kit, until step 6: opened before it their select computed appearance: auto and 0 8px 0 12px, so it kept the operating system's list. They load system/index.css now and the picker arrived with the move, by that step alone. Nothing was patched onto a screen to make it look done here.
<div class="field">
<label for="sub-freq">Billing frequency</label>
<select id="sub-freq">
<option>Monthly</option>
<option>Every 4 weeks</option>
<option>Quarterly</option>
<option>Yearly</option>
<option>Custom</option>
</select>
</div>