Switch row

A preference stated as a sentence a person agrees with, and the checkbox that answers it. Six of them carry every promise this product makes about what it will tell somebody and what it will hold. It is also the component that makes the 20px checkbox acceptable, because the row is the target and the box is not.

Molecule2 grey pagesone form2 states, both added 2026-08-12hosts 1 atom

Anatomy

A label wrapping a control and two lines of text. Everything below is drawn by design/system/components/switch-row.css and by 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 name is a name and not a control. There is no switch in this product, native or drawn. The class says .switch and what it holds is a checkbox, which is worth knowing before somebody builds a toggle because the class implied one.

Variants and sizes

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

The 20px checkbox, and why this row is the answer to it

The control is 20px and WCAG 2.5.8 asks 24px. It is a named carry, recorded twice in docs/decisions.md on 2026-08-11, and it belongs on this page rather than only on the atom's, because this row is the thing that makes it acceptable. The atom cannot make that argument about itself.

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. The label wraps the input, so a click anywhere in this row toggles it. The 20px box is not the target a person hits
How big is the real target?44px or more, per the decision of 2026-08-11. This row carries two lines of text and --space-16 of padding top and bottom, so it stands well clear of the floor before the text is counted
Why not just set the box 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 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 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, on the checkbox's page, in docs/decisions.md and in docs/inventory.md, in the same words in all four places
What is NOT here Why the cell is empty Verdict
A second ring on the box Closed, and the empty cell is now the correct state rather than the finding. Focus lands on the input because a label is not focusable, and until 2026-08-12 that drew a 2px ring around a 20px box at the left edge of a 74px target. base.css carried label:has(input) in its focus-visible list, which looks like somebody meaning to solve exactly this, and it was dead: measured on Settings at both viewports with the input focused, the row matched label:has(input) and never matched :focus-visible, because a label cannot. It is deleted from base.css now. The ring is the row's, drawn here through :has(input:focus-visible), and checkbox.css stands its own down scoped to .switch so a checkbox anywhere else keeps it. Exactly one ring, measured closed 2026-08-12
two lines, two files
A locked or Pro variant Forbidden with a reason rather than missing. "A free trial is ending soon" is a Pro feature shown to a person on Free, and the product deliberately leaves it enabled and unchecked rather than locking it or greying it. D3 puts the paywall at depth and never in front of basic visibility, and a preference a person can read and cannot touch is a paywall wearing a preference's clothes prohibited
A destructive tone Forbidden with a reason. Turning a preference off is not a failure and not a warning. DESIGN.md spends clay on our failure only, and a switch that turns red when a person declines something is the product arguing with them. The two positions are equal and the row says so by looking the same either way prohibited

When to use it

A switch row 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.

The claim is a sentence and the line under it is an example, not a hint. "A price goes up" is followed by "Like 'Netflix went up by $2.50'.", which shows a person the actual thing they are agreeing to receive. A preference screen that states a rule without showing its consequence asks somebody to imagine it, and this product's audience imagines the worst version. That is why the second tier exists and why it is written in the product's own voice rather than as a setting description.

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 switch row is a claim that off is available, and offering one that turns off nothing spends the trust this product is built on to look configurable.

The rule, and the anti-rule

A preference that lands here

Two positions, both visible at once, taking effect on the spot with no submit. The person reads the sentence, sees the answer and changes it or does not. Nothing else on the screen has to be touched for it to count.

Use a nav row instead

A line that leaves the screen is the Nav row. The two stand within a few hundred pixels of each other on Settings and they are opposites: one changes something without going anywhere, the other goes somewhere without changing anything. The chevron is the difference a person reads, and putting a chevron on a preference or a checkbox on a destination breaks the only signal that tells them apart.

Yes or no, and nothing else

One question with exactly two answers. Several of them stack in a section, each independent of the others, and together they read as a paragraph about what the product will do.

Use a form field instead

One answer out of three or more, or an answer a person types, is the Form field with a Select or a Text input inside it. Settings carries both shapes on one screen, so the distinction is visible there: "Currency" has three answers, a label above the control and the hint "Every amount in Tendd is shown in this currency"; "A price goes up" has two answers and a sentence beside it. Checkboxes that are secretly exclusive are the oldest bug in forms, because nothing stops a person ticking two and the product then has to undo what they just did. The field's own label and hint belong to the Form field molecule, which is why the control above stands bare here.

States

The row has one of its own now, and it took one line. Founder's review of the components, 2026-08-12. The row is a pointer target, it is 44px or more at every occurrence, and clicking anywhere in it toggles the preference; until that line it answered none of that, and a person could not tell that the whole row was the target rather than the 20px box at its edge. Every other row in this product does answer: the subscription row, the nav row, the door and the preset tile all take --bg-hover and nothing else. A settings row that did not was the odd one out, which is why the fix needed no new token and invents nothing.

Hoverlight
--bg-hover, measured rgb(244, 247, 248) on the hovered row and nothing on its three neighbours. --bg-hover and not --bg-recessed: one value today, two roles, one a resting surface and the other a transient response
Hoverdark
the same role, the other half of the pair. The fill goes lighter here and darker in the light theme, because a hover moves away from its ground and the ground swapped ends
Focus visiblelight
--color-focus at 2px and 2px of offset, on the whole row and not on the box. Measured rgb(28, 106, 118) around 328px at 360 and 620px at 1280, exactly one of the four rows carrying it
Focus visibledark
the same role, the other half of the pair: rgb(107, 176, 186), same width, same offset, same single ring. The row's height is unchanged at rest and focused, which matters because an outline is painted outside the box and takes no space

It moves nothing and adds no edge, which matters more here than on a lone control: four of these stack in a column with a divider between them, so a border or a padding change appearing under the pointer would shift the three rows below it. Measured on Settings at 360 and at 1280: the four rows keep their heights to the hundredth with the pointer over the first one, and the fill runs the full width of the row including its 2px side padding.

The row, live, and there is nothing to snapshot

Live, and worth trying rather than reading: hover the row and the whole row lifts, hover the box and the accent deepens as well, press Tab and the ring lands on the row.

The ring is the second half of the same review, and it took two lines in two files. A label is not focusable, so focus goes to the input, and until 2026-08-12 that meant the thing answering the keyboard was a 20px box at the left edge of a 74px target: a third the size of the thing a person aims at. base.css listed label:has(input) among its focus-visible selectors, which looks exactly like somebody meaning to solve this, and it was dead: a label matches it and can never match :focus-visible. Measured on Settings at both viewports with the input focused, then deleted from base.css with that measurement recorded there. So docs/backlog.md filing this under "both are markup decisions" does not hold: the working selector is :has(input:focus-visible), and it is CSS.

Split across two files so that each one only ever speaks for itself. This file draws the ring on the row; checkbox.css stands its own ring down, scoped to .switch so a checkbox anywhere else keeps it. Either half alone is worse than what it replaced: the row's ring on its own renders two concentric petrol rings on every preference, and writing outline: none on the input from here would be a molecule repainting a hosted atom's state, which is the exact defect this round exists to remove. Neither half was written until both were authorised. input appears once in this component's CSS, inside :has(), which is a question about the row rather than a declaration about the box.

Measured on Settings at 360 and 1280, in both themes. The ring is 2px solid at 2px of offset around the full row, 328px wide at 360 and 620px at 1280; exactly one of the four rows carries it; the four row heights are identical at rest and focused (74 / 92 / 74 / 73 at 360, 74 / 74 / 74 / 73 at 1280) and the document height does not move, because an outline is painted outside the box and takes no space. :has(input:focus-visible) rather than :has(.check:focus-visible) on purpose: the atom was renamed .check the same day and the markup pass that adds the class has not run, and a ring that waits for a markup pass is a ring that is absent for as long as the pass takes.

The technical half

ReadsWhich tokenGrowing from
the claim--text-primary--ink
the example under it--text-muted--muted, the single secondary tier
the divider it draws--line-divider--hairline-soft. A decorative separator, so no 3:1 applies: nothing here has to be found by touch
the two text sizes--type-body, --type-metaprimitive, straight: geometry has nothing to override
gap, padding, the 2px lift--space-2, --space-16primitive, straight
the row under the pointer--bg-hover--panel, and --panel-dark in the dark theme. Added 2026-08-12. Same role the subscription row, the nav row, the door and the preset tile read
the row under the keyboard--color-focus--petrol, and --petrol-dark in the dark theme. Added 2026-08-12, 2px at 2px of offset, the same ring base.css draws for the whole system. It lands on the row rather than on the box because the row is the target
the box, its tick, its accentnonecheckbox.css. input appears once in this file, inside :has(), and no property of the box is set here
its target sizenonethe content and the padding. The row reads neither --tap nor --row, and it clears 44px on both screens that carry it

Lives in design/system/components/switch-row.css. Stands on 2 grey pages, 6 rows: Settings, four of them and all four coloured, and Data and privacy, two of them, still grey and arriving 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 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, so it is not a detail a later refactor may tidy away.