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

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.

AxisValueUsesRenderedThe rule
emphasis .btn65 Amend m the default, and most buttons are it. An action that is neither the one main move nor the way back
.btn--primary53 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--quiet33 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 label74 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 + .key83 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.

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

Amend m Accept a Cancel Esc File the rejection Enter

light, the pair

Amend m Accept a Cancel Esc File the rejection Enter

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.

Point at one

hover. The ground moves one step and the boundary rises to the reading ink.

Hold the pointer down

active. One further step of the same ramp, and the accent presses into its own hue.

Press Tab

focus-visible. The ring appears for the keyboard and stays away from the mouse.

Try the fourth one

out of reach. It takes no pointer state at all, which is the state.

What each state resolves to, read off the live element

StateHow it is producedWhat it overridesDarkLight
restnothing--bg-page
--line-control
hoverthe pointer is on it--bg-hover
--line-hover
activethe pointer is down--bg-active
focus-visiblereached by Tab, never by a click--color-focus, through --focus-ring in base.css
out of reachnothing. 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

WhereThe named difference
hover, everywhereThe 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--primaryThe 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--primaryIt 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--quietThis 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

RoleSurfaceGrows fromWhere on the button
--bg-pagefill--warm-950the default ground
--bg-actionfill--amber-500the primary's ground
--bg-hoverfill--warm-900under the pointer, default and quiet only
--bg-activefill--warm-800pressed, default and quiet only
--text-primaryink 4.5:1--warm-50the label, 14.92
--text-secondaryink 4.5:1--warm-200the quiet label, 7.56
--text-on-actionink 4.5:1--amber-950the primary label, 7.48 on its own plate
--line-controlline 3:1--warm-400the default boundary, 3.98
--line-separatorline, exempt--warm-850the quiet boundary, 1.18, and the exemption is written in btn.css
--line-hoverline 3:1--line-recordthe boundary under the pointer and while pressed
--color-focusline 3:1--amber-500the ring, through base.css
--opacity-disabledneithermeasured, .54 and .61the 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

<a class="btn btn--primary" href="...">Accept <span class="key">a</span></a> <a class="btn" href="...">Amend <span class="key">m</span></a> <a class="btn btn--quiet" href="...">Cancel <span class="key">Esc</span></a> <span class="btn" aria-disabled="true">File the rejection <span class="key">Enter</span></span>

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.

Seven places it stands