Atom · the reference component
Button
157 instances on 57 screens, and the most interactive thing in the product. The five state tokens of this system were founded on it, which is why this page carries more argument than the sixty one that follow it: every one of them takes hover, active, focus and out of reach already decided.
The button is painted by design/system/components/btn.css, and this page links the same index.css a screen does. It cannot drift from the product, because there is nothing here to drift.
Anatomy
.btnthe control itself. Ana113 times, abutton5 and aspan4, and the class carries all three. What varies is whether the action navigates, submits or is out of reach, and none of that changes the drawing.btn--primarythe emphasis modifier. It repaints the ground, the ink and the boundary in one, so nothing about a primary button is decided anywhere else- text nodethe label. No class, on purpose: the button has one text zone and a class for it would be a name for the only thing there
.keythe shortcut. Its own atom, on its own page, and its border follows the button's colour rather than carrying one. This is the zone to edit when a shortcut looks wrong, and it is not part ofbtn.css
Nothing else. The product contains zero svg elements, so no button anywhere carries an icon: two thirds of the content axis of the control census is empty, and it is empty by decision. Design principle 5 puts density first, and the four verdict actions already carry a letter, so an icon would be a third signal for one action.
Variants
Two axes and no third. Each carries the rule that picks a value, because a matrix without rules is a table of facts from which the next person invents a reason again. The counts are read off the rendered screens, not off the first declaration in the stylesheet.
| Axis | Value | Uses | Rendered | The rule |
|---|---|---|---|---|
| emphasis | .btn | 65 | Amend m | the default, and most buttons are it. An action that is neither the one main move nor the way back |
.btn--primary | 53 | Accept a | one per zone, and never two. It marks the main action of the region it stands in, so a second one in the same foot removes the meaning from both | |
.btn--quiet | 33 | Cancel Esc | the way back out. It stands beside a primary and never alone: a quiet button on its own reads as something disabled | |
| content | label | 74 | Open the queue | not a decision about the button. The key is there when the action has a shortcut, which is a fact about the action, and the empty span collapses by itself |
label + .key | 83 | Reject r | ||
| size | no second value | – | prohibited | The button has one size and gets no other. Density is design principle 5 and the scale already bottoms out at 11px; a place that needs something smaller than this needs a different component, and it has two: chip for a filter and key for a shortcut |
| width | no full width value | – | belongs to the container | Full width is a one column container, not a decision about a button. Six buttons run full width at 360 in the pane foot and one does in .out-act, and every one of those rules lives with the container. A .btn--block here would let a button go full width inside a row, where nothing wants it |
One row of the step 2 register was wrong and writing the component found it. The register listed out of reach, [aria-disabled], as a fourth value of the emphasis axis. It is not an emphasis: it does not say how loud the action is, it says the action cannot be taken right now, and it can fall on any of the three values above. It is a state, it is in the state block below, and the axis has three values rather than four.
.btn--primary-narrow is not on this page and will not be. It was the same primary with a viewport twin folded into its name, six instances, and step 2 collapsed it to .btn--primary plus .only-narrow. The product still carries the old name until the reconciliation at step 6 renames the markup, which is why btn.css deliberately holds no rule for it.
When to use it
A button is where the analyst rules. Four of them carry the whole main job: Accept, Amend, Reject and Escalate stand in the foot of the detail pane, and between them they are the decision that DESIGN.md and the main job in CLAUDE.md are about. Everything else a button does is secondary: opening the queue, copying an address, sealing a brief, trying again after a write failed.
Where she meets it. Rasha is on the case pane, has read Clerk's verdict, and is deciding. Design principle 3 says the override is one key and it teaches, so every verdict button carries its letter and the letter is the real interface: the pointer is the fallback, not the path. That is why the key is 83 of 157 uses and why hover is deliberately quiet while the focus ring is not.
Rule and anti-rule
Do
Two actions in one foot: one primary and one quiet. The main move is named, the way back is available and does not compete, and the pair reads in one glance without either being read twice.
Do not
Not for something that only navigates and sits inside running text. That is link, which takes the ink of whatever encloses it and does not claim a control's boundary. A button here says a decision is being taken when nothing is.
Two more, and each names the component that should have been used. A word that reports a condition is state, not a button: escalated and unrecorded are things that are true, not things you press. A shortcut shown on its own is key: the keyboard map is 22 keys and no buttons, because none of them is pressable there.
Limits
Rules of composition and context, which no anti-rule can carry: nothing else is the right component to take, and what is wrong is the count or the neighbour. Counted on the grey corpus at stage 09, and every one of them is a function in design/kit/checks/rules.mjs. Full table with sources on Architecture.
- R1. Not more than one
.btn--primaryper layer, and a foot does not compete with it. A second is allowed only as the viewport twin of the first, or in a modal layer over the screen.
States
Nothing in this block is a picture of a state. Every button below is a real one on the same index.css a screen links, and every value in the table is read out of a live element when the page renders. A screenshot of a state is a second copy of the component: it has to be re-taken, it goes stale without saying so, and the first one taken here came back showing a button with no focus ring at all, because focus set from a script does not match :focus-visible. It looked entirely plausible.
The bench, both themes, both alive
dark, shipped
light, the pair
Both halves declare their own theme, so the switch in the panel changes the page around them and not them. Neither is a rendering of the other.
hover. The ground moves one step and the boundary rises to the reading ink.
active. One further step of the same ramp, and the accent presses into its own hue.
focus-visible. The ring appears for the keyboard and stays away from the mouse.
out of reach. It takes no pointer state at all, which is the state.
What each state resolves to, read off the live element
| State | How it is produced | What it overrides | Dark | Light |
|---|---|---|---|---|
| rest | nothing | --bg-page--line-control |
||
| hover | the pointer is on it | --bg-hover--line-hover |
||
| active | the pointer is down | --bg-active |
||
| focus-visible | reached by Tab, never by a click | --color-focus, through --focus-ring in base.css |
||
| out of reach | nothing. It is a rest form, not a reaction | --opacity-disabled |
Every figure above is computed here, not transcribed. A changed token changes this table with it, which is the whole reason there is no picture in this block.
Four things the states say, and the fourth changed the product
| Where | The named difference |
|---|---|
| hover, everywhere | The ground alone would not carry it. --bg-hover is 1.05 off the dark page, which is right for a row the width of a pane and far too quiet for a control 120px wide, so the boundary is what you actually see. It is read off the product rather than invented: .z1 .kmap:hover and .toast .t-x:hover both raise the border to the reading ink. |
hover, on .btn--primary | The ground does not move. The accent ground is what makes this the one main action of its zone, and taking it off the accent for the length of a pointer pass would spend the product's scarcest signal on the cheapest event. Only the boundary answers, from 1.00 against its own fill to 1.93. |
active, on .btn--primary | It presses into its own hue, --amber-600, rather than onto --bg-active, which would read as the action leaving. The one primitive stage 08 added, and the label is what stopped it going deeper: --text-on-action measures 5.03 on it and 4.21 one step further down. Elsewhere :active is the one state the product had nowhere at all, on any control. |
hover, on .btn--quiet | This is the variant where hover produces an edge that was not there. At rest the quiet boundary is 1.18, the role declared to carry no meaning, and it is exempt from 1.4.11 because the label at 7.56 is what identifies the control. Under the pointer the edge arrives in full, which is the one moment it is needed. |
Three answers are the same and are written rather than shown twice. .btn--primary and .btn--quiet focus exactly as the default does: one ring, declared once in base.css, and the 1px outline offset is load bearing rather than decorative, because on the accent ground the ring would otherwise measure 1.00 against the button it surrounds. Neither variant has an out of reach rendering anywhere in the product: all nine instances are a plain .btn. And no button has a visited or a selected state, because a button is not a place.
Motion
Response, 120ms, the browser's own ease. What moves is a ground and a boundary, the four verdict controls, and 86 of the product's buttons stand in one foot. Named one by one rather than as all, because all animates what nobody ordered and drags the layout properties in behind it. Never a size and never a position: those make the browser recalculate the layout of the page on every frame. Under prefers-reduced-motion it is 1ms, and this component does nothing to make that happen: it reads var(--dur-fast) and the token is redefined once. Full reasoning on Motion.
What it reads, and where it stands
| Role | Surface | Grows from | Where on the button |
|---|---|---|---|
--bg-page | fill | --warm-950 | the default ground |
--bg-action | fill | --amber-500 | the primary's ground |
--bg-hover | fill | --warm-900 | under the pointer, default and quiet only |
--bg-active | fill | --warm-800 | pressed, default and quiet only |
--text-primary | ink 4.5:1 | --warm-50 | the label, 14.92 |
--text-secondary | ink 4.5:1 | --warm-200 | the quiet label, 7.56 |
--text-on-action | ink 4.5:1 | --amber-950 | the primary label, 7.48 on its own plate |
--line-control | line 3:1 | --warm-400 | the default boundary, 3.98 |
--line-separator | line, exempt | --warm-850 | the quiet boundary, 1.18, and the exemption is written in btn.css |
--line-hover | line 3:1 | --line-record | the boundary under the pointer and while pressed |
--color-focus | line 3:1 | --amber-500 | the ring, through base.css |
--opacity-disabled | neither | measured, .54 and .61 | the whole control when it is out of reach |
Geometry comes straight from primitive with no role in between, because a radius and a padding have nothing a theme could override: --size-md, --font-sans, --radius-ui at zero, --space-2 and --space-3.
Copy this
The file is design/system/components/btn.css. The out of reach form is a span and not a disabled button on purpose: it is never focusable, so nothing sends the keyboard to a control that cannot answer.