Checkbox

The product's only on and off control, and it is a native input rather than a drawn switch. It carries the four preferences on Settings and the two on Data and privacy, which is to say it carries every promise this product makes about what it will tell a person and what it will hold.

Atom2 grey pagesone form4 states, both themes

Anatomy

One box and one value, which is what makes it an atom. Everything below is drawn by design/system/components/checkbox.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.

The box, its edge, its corner and its tick are drawn by the browser. Nothing in tokens.css reaches them except through accent-color, and reaching them properly means appearance: none and rebuilding the control out of a pseudo-element, which is a redraw and not a move. This stage does not move a pixel.

Variants and sizes

The checkbox has one form, so there is no matrix to draw. All six instances in the product render identically at 20px, and the census measured .switch input as one of the sixteen families with a single value on every property, at both viewports. Checked against unchecked is not a variant: it is the control's value, and the difference is drawn by the browser out of accent-color. On an unchecked box no token of ours is visible at all.

The 20px, and why it is not being fixed here

The control is 20px and WCAG 2.5.8 asks 24px. This is a named carry, recorded twice in docs/decisions.md on 2026-08-11, and it is on this page for the same reason it is in that file: a defect that is carried silently is a defect that ships.

The questionThe answer, and where it is written
What does 2.5.8 measure?The target, meaning the whole area a pointer may land on to operate the control. Here the control sits inside label.switch, and a click anywhere in that label toggles it
How big is the real target?44px or more, per the decision of 2026-08-11. The Settings rows carry two lines of text, a 12px top and bottom padding and a divider, so they stand well clear of it. The raw 20px box is not the target a person hits
Why not simply set it to 24px?Because 20px is a kit value, and this stage changes a value only by a named decision, never as a side effect of moving a component into a file. A resize here would move pixels on Settings and on Data and privacy, and the pixel comparison at step 8 would report a difference with no line in any of the three lists that are allowed to produce one
So what closes it?A founder decision, at the responsive stage or before it, with a number and a reason. Until then it stands here, in docs/decisions.md and in docs/inventory.md, in the same words in all three places

There is no switch component in this product, native or drawn, and the class name .switch on the row is a name rather than a control: the row is called a switch row and what it holds is a checkbox. That is worth knowing before somebody builds a toggle because the class implied one.

When to use it

A checkbox carries a preference that is on or off and takes effect where it stands. All six in the product answer one of two questions. What may Tendd tell you about: "A price goes up", "A payment does not go through", "A free trial is ending soon", "Weekly email digest", the four on Settings. And what may Tendd do with your data: "Refresh my bank data automatically", "Use my activity to improve Tendd", the two on Data and privacy. Each is a sentence a person agrees with rather than a setting they configure, which is why the row shows the sentence and a plain-language example underneath it.

Both positions have to be real. The grey original of Data and privacy states the rule in its own comment: "Two real toggles and no theatre. There is no advertising switch and no personalisation switch, because we do neither, and offering a control with only one legal position implies we could." A checkbox is a claim that off is available. Offering one that cannot be turned off, or that turns off nothing, spends the trust this product is built on to look configurable.

Nothing about it shouts. The tick takes petrol, which is one of the accent's four permitted jobs read as a current selection, and the row around it is a hairline divider and two tiers of grey text. There is no count, no red, no "recommended" badge and no default marked as better than the other: a preference screen that argues with a person is a preference screen they stop trusting, and this product's whole claim is that it says what it does with the data every time.

The rule, and the anti-rule

Use a checkbox

A preference with two states, both of them visible at once, that takes effect where it stands and needs no submit. The person reads the sentence, sees the answer, and changes it or does not.

Use a button instead

An action that commits or leaves the screen belongs to the Button. A checkbox that means "yes, do the thing" is a control whose state is a promise the product has not kept yet, and a person then hunts for the button that makes it real. If ticking it starts something, it was never a preference.

Several answers at once

Four independent yes or no questions on Settings, each answerable without touching the others. Stacked in a divided list, they read as a paragraph about what the product will say to you.

Use a select instead

One answer out of three or more is the Select. Checkboxes that are secretly exclusive are the oldest bug in forms: nothing stops a person ticking two, so the product has to untick one for them, and an interface that undoes what a person just did is the opposite of calm.

Limits

U13, context. PRESS, and this is the one place the nudge is a SCALE. A 20px box cannot travel 2px without looking dislodged, and the box IS the control rather than a part of one, so the press reads as the box yielding under the finger: it shrinks to 0.94, the mirror of the merchant mark’s 1.06 lift. The tick is drawn by the platform through accent-color and cannot be animated at all. Everything in this product that moves travels exactly one --nudge, which is 2px, and nothing anywhere travels two. Hover and 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.

States

Four states, and they exist because the checkbox is interactive. Each one is a token override rather than a style of its own, and on this control the overrides are unusually thin: everything a state can reach on a native checkbox is accent-color and the focus ring, because the box and the tick are the browser's.

Two of the four are authored, and both move zero pixels today. kit.css gave the checkbox no hover and no disabled rule. Hover follows the filled button exactly, one step deeper into the accent through a token; disabled repaints from --bg-recessed, the same role the disabled button uses. No page carries a disabled checkbox, so nothing on any screen changes. The one place that looks like it should is "A free trial is ending soon" on Settings, which is a Pro feature on a Free account, and the product deliberately leaves it enabled and unchecked rather than locking a person out of reading it.

The hover is on the control, not on the row. The row is label.switch and belongs to the Switch row molecule; an atom that repaints its host is the unnamed variant this stage exists to prevent. If the row should light up under the pointer, that decision belongs to switch-row.css and to the person who owns it.

Rest, live

The third one is genuinely disabled, by the attribute rather than by a class the product does not carry. Faking a state with .is-disabled on a native control would document something the product cannot do, and it would also lie to a screen reader, which reads the attribute and not the class.

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.

Restlight
--bg-action through accent-color, and on the unchecked box nothing of ours at all
Restdark
the accent climbs the scale in the dark theme rather than mirroring, so the tick stays legible against a dark box
Hoverlight
--bg-action-strong. AUTHORED, and visible only on a checked box: an empty box has no fill of ours to deepen
Hoverdark
the same token, and its direction inverts with the theme: petrol deepens on light and lightens on dark, because a hover moves away from its ground
Focus visiblelight
--color-focus, 6.23:1 as a line. It matters more here than on a wide control: the row is not focusable, so this 2px ring on a 20px square is the whole of the keyboard's feedback
Focus visibledark
--color-focus, 6.82:1. The ring is 2px at 2px offset and sits outside the box, so a focused checkbox moves no neighbour and the row does not jump
Disabledlight
--bg-recessed, the same role the disabled button fills with. AUTHORED, and used by no screen
Disableddark
no opacity token, here or anywhere: the paint lands on a role that already exists, and an opacity token would either move the product or sit unread
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/checkbox.html move the pointer away -> screenshot #live-rest -> screens/checkbox-rest-<theme>.png hover #shot-on -> screenshot #live-rest -> screens/checkbox-hover-<theme>.png focus #shot-on, press Tab, assert :focus-visible -> screenshot #live-rest -> checkbox-focus-<theme>.png blur, screenshot #live-rest -> the disabled one is already in frame -> screens/checkbox-disabled-<theme>.png Shoot the frame at 2x or the 20px box arrives as mush and the states cannot be told apart. 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

ReadsWhich tokenGrowing from
the checked fill and its tick--bg-action, through accent-color--petrol
the same under the pointer--bg-action-strong--petrol-deep, and --petrol-deep-dark in the dark theme, which is lighter and not darker
the focus ring--color-focus--petrol
disabled--bg-recessed--panel, a role that already existed
the box, its edge, its corner, its tick shapenonethe browser. accent-color is the whole surface this system has on a native checkbox
its sizenone20px, a literal and a named carry against the 24px of WCAG 2.5.8. See the block above

Lives in design/system/components/checkbox.css. Stands on 2 grey pages and 6 checkboxes: settings, four of them, and data-privacy, two. Coloured screens carrying it: Settings, which holds all four. Data and privacy is still grey, so its two arrive with the rollout.

<label class="switch"> <input type="checkbox" checked> <span><span class="t">A price goes up</span><span class="h">Like "Netflix went up by $2.50".</span></span> </label>

The input has no id and the label has no for, and that is correct rather than sloppy: the label wraps the control, which associates them and makes the whole row the target. It is also what the 20px carry rests on.