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.

Atom4 grey pagesone form6 states, both themes

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.

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

Use a select

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.

Use a text input instead

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.

Three or more values

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.

Not for on or off

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.

Restlight
--line-control, --bg-surface, --text-primary
Restdark
the same tokens, the other half of each pair. The browser's arrow follows color, so it inverts with the theme without a rule
Hoverlight
--line-control-hover, the edge alone
Hoverdark
the edge STRENGTHENS in both themes, 3.46 to 5.78 on light and 5.15 to 6.40 on dark. The value this replaced was lighter than the resting edge, so the boundary used to weaken under the pointer
Focus visiblelight
--color-focus, 6.23:1 as a line
Focus visibledark
--color-focus, 6.82:1. The ring is 2px at 2px offset and sits OUTSIDE the box, so focusing a select moves no neighbour
Openlight
--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
Opendark
the same roles, the other half of each pair, measured: rgb(22,31,33) on the list, rgb(42,53,56) on its edge, rgb(29,40,42) under the pointer, rgb(107,176,186) on the chosen one. There is no shadow under it in either theme: the Flat Paper Rule holds for a floating surface too, and the one elevation in the system belongs to the dialog sheet
Invalidlight
--bg-failure and --line-failure. Never red: the persona closes any app that shows her red
Invaliddark
the wash and the edge both swap to the dark clay pair, and the focus ring turns clay with them, so a keyboard person is not told two different things at once
Disabledlight
--bg-recessed, --line-divider, --text-muted
Disableddark
the same three roles the disabled button and the disabled input repaint from, so every disabled control in the system looks like one decision rather than three
Retake, 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

ReadsWhich tokenGrowing 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-mutedthree roles that already existed
corner and floor--radius-sm, --tapprimitive, straight: geometry has nothing to override
the arrow--text-muted, and nothing elsethe 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-4the 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-primarythe 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-focusthe 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>