Button
The etalon of the system: the first component written and the template every other one follows. It stands on more pages than anything else in the product, it is the loudest object after the monthly total, and it is the reason the state tokens exist.
Anatomy
One zone and one content slot, which is what makes it an atom. Everything below is drawn by design/system/components/button.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.
.btnthe atom itself: an inline-flex box with a 44px floor, a control radius and a one pixel edge. Rendered as anaor abutton, and the two are identical by design.primarythe emphasis modifier that fills it with the accent, and the only modifier this atom has leftmin-heightreads--tap, the 44px floor the project holds everywhere. Never overridden downward
Variants and sizes
Two modifiers since 2026-08-20, and the second one arrived by merge. .primary is the tier and .soft is the quiet form: weight 400 instead of 600, and a hover that keeps the edge and the ink but refuses the fill. It was .cut, its own family of six rules in save-focus-candidate.css, until the founder merged it: measured on the rendered page after the fold, .cut was this button minus exactly those two things, identical on seventeen other properties. The name was .quiet for about an hour and counts.cjs caught it - .quiet is already the Quiet line component on 14 pages, so the markup would have worn two components at once. One word, one meaning.
One axis, and it used to be two. The axis carries the rule for choosing on it, because a matrix without a rule is a table of facts from which everybody invents a reason again. Width is deliberately not an axis: 100% against auto is layout, so the button in a card and the button in a plan panel are the same button. Size stopped being one at the founder's review of the foundations, when the scale removed both of .btn.compact's differences at once; the table below keeps the record of it and draws no specimen, because the modifier no longer exists.
| Emphasis One filled accent per zone, and it is the zone's main action. If two want to be filled, the screen is asking two questions |
Tokens | Where it stands |
|---|---|---|
Filled .btn.primary |
--bg-action --text-on-action |
33 grey pages. The one action a zone is for |
| Start Tendd Pro | ||
Outline .btn |
--bg-surface --line-control --text-body |
46 grey pages. A real alternative, not a ghost: the two doors of this product are equal by decision, so the second door has to be legible |
| Maybe later | ||
Inverse .btn.inverse, .btn.inverse.primary |
deleted 2026-08-12 | Authored at stage 07, worn by nobody, and gone. Both occur 0 times on the 28 coloured screens and 0 times on the 57 grey pages, counted by parsing every class="..." token in both corpora and again by querySelectorAll in a browser on all 83 pages. It could not have been extracted, because greyscale has no fill to invert, so it was written from the argument that a petrol ground needs a control that survives on it. That ground exists in exactly one place, the landing, and the landing wears .lp-btn five times and has never worn this. Two rules and two hovers describing a control the product does not have is four rules a reader has to price before deciding they are not the answer. Nothing moved: a rule with no markup paints nothing. It comes back, authored again, the day a petrol band ships with a control on it |
| Size Set by the CONTAINER, never by importance. The same action is 14px in a page-level action row and 13.5px in a tighter one because the row is tighter, not because the action matters less |
Values | Where it stands |
|---|---|---|
Default .btn |
14px / 0 18px | 46 places: the page action row, the empty state, the gate |
| Add a subscription | ||
Compact .btn.compact |
deleted at step 4 | It was 13.5px against 14px and 14px of side padding against 18px, and the founder's review of the foundations folded both pairs onto one step each: the modifier became byte identical to the base. A modifier that modifies nothing is worse than none, because the next reader spends a minute finding out. No specimen is drawn here, which is the correction: this row went on showing one after the class had gone, and a page that draws a variant the CSS does not have is a page that lies |
| Large | - | Forbidden with a reason rather than missing: nothing in this product is more important than the monthly total, and a button competing with it at that scale is the One Number Rule broken. If a screen seems to need one, the screen is answering two questions |
The default row says 14px / 0 18px and the code says otherwise, and the code is right. Both values fold onto the scale the founder adopted: the size is --type-body and the side padding is --space-16. The row is left as the record of what the button measured before the fold, which is what the pixel comparison reads it against.
When to use it
A button is where a person commits: connects a bank, adds a subscription, starts Pro, retries after a failure. It is the loudest object the product owns after the monthly total, which is why the accent may fill exactly one per zone. On a screen whose job is to look rather than to act, there is no filled button at all, and Home is exactly that screen: it carries two outline buttons and nothing louder.
Labels come from voice/docs/microcopy.md and say what will happen, in the person's words: "Connect your bank", "Add a subscription", "Start Tendd Pro - $69 a year". The amount is on the button because nobody should learn what they are being charged after the tap. A button never says "Submit", "OK" or "Manage", and never carries an exclamation mark.
The rule, and the anti-rule
The longest label in the product, 279px at 360, and it does not fold. A button broken over two lines stops reading as one thing to press: the eye has to reassemble it, and it breaks the 44px floor upward into an odd 65 or 86 so a row of them stops lining up. .btn carries white-space: nowrap for exactly this.
Tendd
Pro
Three lines, three different widths, and a control that no longer looks like a control. If a label cannot fit its container the answer is a shorter label, which is a question for voice/docs/microcopy.md, or a wider container. It is never a folded button. Stage 07 paid for this once already: the onboarding column was widened from 620 to 760 because the plan button wrapped, and that was the layout paying for something the component should have guaranteed.
One exception, added 2026-08-13, and it is the rule above meeting a reader rather than a builder. The rule holds because a label is authored once and can be measured once. That stopped being true the day the type scale became rem: the text size is now the person's, so no label can be authored to fit every root size, and "the label is too long, take it to microcopy" has no answer left to give. Measured on design/upgrade.html at a 390px viewport, where the plan card's track is 358px: "Start Tendd Pro - $7.99 a month" as a non-wrapping button is 249px wide at the browser default, 302 at a 20px root, 329 at 22px and 356 at 24px. At 22px the card is already 363 in a 358 track; at 24px the page itself scrolls sideways by 16px, and Chrome's own font size menu offers that value under Very large. So .app .plan-opt .btn takes white-space: normal, and nothing else in the product does. It is scoped, because this is the only button whose label is a sentence carrying a price and a period rather than a verb and its object; and it is written in button.css rather than in plan-option.css, because white-space is the button's property and a host reaching in to change it is the defect the plan card's own file names twice. Zero pixels move at the default: a wrapping button that already fits keeps its width and its 44px height.
And what the exception does not cover, because the general answer is the founder's. Swept after the fix, recounted on 2026-08-14 against the true corpus: 672 renders, 32 pages by 7 widths from 320 to 1600 by 3 root sizes; the first pass ran 609 on a page list of 28 that predated the four Alerts screens. Everything at 360 and above is clean at every root size. At 320 with a 24px root three things still cross the edge: .btn.primary "Try another payment method" on upgrade-payment-failed, 329px in a 288px zone; the app bar's plan chip on the five History and Trends screens, pushed out by a brand lockup that grew beside it; and a .btn on alerts and alerts-empty by 7px, which only the recount could see. The rule above came from a founder finding on the etalon page, so whether it survives a text size the product no longer controls is not a builder's call. Two honest answers: an exception per host as each appears, which is two so far, or nowrap comes off .btn entirely, which stage 09 measured as zero pixels at the default. Carried in docs/backlog.md.
The recovery action of an error state, standing alone in an action row. It commits: it re-runs the thing that failed. Five error screens in this product play it exactly this way.
Inside a calm sentence that is explaining what happened, the same job belongs to the wash block and its own inline link, not to a button. A filled petrol button inside an explanation shouts over the explanation. That is the one place in this product where "Try again" is deliberately not a button, and the census found it playing three different ways before this rule was written down.
Limits
U1, composition. One filled action per zone. A zone is a block a person reads as one thing: the screen's own content column, or a .panel, .card, .sheet, .rstep or <form> inside it. The second action of a zone takes .btn without .primary. Counted across the whole grey product: 15 of 17 screens carry at most one.
U7, context. A wait carries no control at all. An edge a person takes is a control; an edge the system takes is not, and a button on a loading screen offers an action the product does not have. Full table with sources and how to check: Usage rules.
U13, context. LIFT AND PRESS, and on this component they are one gesture in two halves. The button rises one nudge to meet the pointer, and the press puts it back on the surface - which is why the pressed state here is level and not a nudge below the line: from the lift, the surface already is one nudge down. A disabled button does neither, because it is still a button and answering a press the product will not honour is a lie told in 2px. The distance rule holds untouched: rest to hover is one --nudge, hover to press is one, rest to press is one, and nothing anywhere travels two. The lift and the release run at --dur-state (150ms) on --ease-arrive; a press runs at --dur-press (90ms), because it answers a finger that is already down. The full language and the live specimens are on Motion; the table with sources is Usage rules.
The lift sits behind @media (hover: hover), and that is not a width query. Rule 8 forbids @media in a screen file; this is a component, and the reason for the query is the gesture itself: it needs a pointer that can rest somewhere without committing. A touch screen has no such state, so on a phone the block never applies and the plain press stays exactly as it shipped - a tap pushes the button down by one nudge. The same fallback covers the keyboard, where :active fires with no :hover beside it.
States
Four states, and they exist because the button is interactive, not because the template has four cells. Each one is a token override rather than a style of its own: no hex and no number appears inside :hover, so the dark theme costs three lines in tokens.css rather than forty edits across the components.
Three of the four now fade, and the fourth deliberately does not. Since 2026-08-16 the product has one state change, colour over --dur-state (150ms) on --ease-state, declared once in base.css on the elements this system treats as interactive. This button is one of them and writes nothing of its own. Hover, active and disabled all move background, border-color and color, so all three fade; focus-visible does not, because the ring is off that property list on purpose. A keyboard user moves faster than 150ms per stop, so a faded ring is always one control behind the caret. See Motion, rule U12.
And since 2026-08-16 the hover also MOVES, because the fade alone was measured and it is close to nothing. The founder’s sentence was “при ховере я не вижу анимации”, and the measurement backs it: on the public page a secondary button’s fill goes from #ffffff to #f4f7f8 on hover, a step of about two per cent, on the one control a person is most likely to hover before they trust the product. The edge moves by one token step beside it. Both are real; at arm’s length both are nothing. So the button now rises one nudge to meet the pointer, and the press puts it back on the surface. The fade did not change and no colour token moved: the fill is still --bg-hover over --line-control-hover, and what was added is 2px of translate, which is composited and reflows nothing.
Rest, live
The third one is genuinely disabled, by the attribute, and as of 2026-08-12 the attribute is the only way to reach the state. .btn.is-disabled shared that rule and occurred 0 times on the 28 coloured screens of the day and 0 times on the 55 grey pages, so it went with the other empty roles. The attribute stayed even though no .btn in the product carries it either, and that is not the same argument twice: disabled is what a browser sets and what a screen reader reads, so the first real disabled button arrives wearing it whether or not anybody remembered a rule. A class that only paints one is a second edition of the state that lies to assistive technology while looking right. One state, one spelling, and the spelling is the one with meaning.
The four, 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.
--bg-action, --line-control--bg-hover, --line-control-hover--color-focus, 6.23:1 as a line--color-focus, 6.82:1. Had the pair been left for later this ring would have been the light petrol at 1.9:1: in the file, invisible on screen--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/button.html
move the pointer away -> screenshot #live-rest -> screens/button-rest-<theme>.png
hover #shot-outline -> screenshot #live-rest -> screens/button-hover-<theme>.png
focus #shot-primary, press Tab, assert :focus-visible -> screenshot #live-rest -> button-focus-<theme>.png
blur, screenshot #live-rest -> screens/button-disabled-<theme>.png
The states are reached the way a person reaches them: a real pointer, a real Tab, a real
disabled 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 |
|---|---|---|
| the filled ground | --bg-action | --petrol |
| its label | --text-on-action | --paper, and --canvas-dark in the dark theme |
| the outline ground | --bg-surface | --paper |
| the outline edge | --line-control | --edge |
| the outline label | --text-body | --slate |
| hover fill and edge | --bg-hover, --line-control-hover | --panel, --muted |
| the focus ring | --color-focus | --petrol |
| 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 |
Lives in design/system/components/button.css. Stands on 38 grey pages and 44 coloured ones, 103 buttons, more than any other component in the product. A sample of the coloured screens carrying it: Home, Add a Subscription, Upgrade, Guided Reveal, the Pro gate, Settings, no account.
<a class="btn primary" href="...">Connect your bank</a>
<a class="btn" href="...">Add them yourself</a>
<button class="btn" type="button" disabled>Try again</button>