Guidelines

Every other page here answers how does this component work. This one answers the question before it: which component, and what a set of them owes each other. Nothing on this page is enforced by the CSS, which is exactly why it is written down.

One choice out of several

Pick by the size of the set, not by how the row looks. A row of separate buttons is the wrong answer at every size: buttons are actions, and a set of them where only one may be chosen says nothing about the exclusivity that is the whole point.

Which control for how many options
OptionsControlWhy
2 to 5segmentedEvery option is on screen, so the reader compares them without acting. It is one tab stop and the arrow keys move inside it.
6 to 12selectPast five, a strip either wraps — and a control that wraps stops reading as one control — or scrolls sideways, which hides options behind a gesture nobody makes.
more than 12comboboxAt this size the reader knows the name of what they want and typing three letters beats scrolling. A list nobody can hold in their head needs filtering, not scrolling.
any number, several at oncepopover of checkboxes, plus tag for what is activeMultiple selection has no native control worth using: `select` with `multiple` is a grey box thirty rows tall on a phone. The panel holds the choosing; the tags outside it hold the state, so the reader sees what is filtering the page without opening anything.

The one case for a row of buttons is a row of actions, where pressing each does something different and none of them is a state.

Several at once has no "all" option

The question that decides the whole shape comes before the table: is choosing two answers together a sensible answer? Departures or arrivals is not — they are two views of one page, so it is one answer and the table applies. Actors and extras is — it is one search with two conditions, so it is several, and the rest of this section applies instead.

An "all" option belongs to exactly one of the two, and the asymmetry is the part that gets missed. With radios it is compulsory: a radio group has no "nothing chosen" state a person can reach, so without it the reader can enter the filter and never leave it. Write it as a real option with an empty value — that is what makes clearing the filter something to press rather than something only the code can do.

With checkboxes it is a defect. Nothing ticked already means all of them, so an "All" box is a button wearing a checkbox: it is announced as checked for a state nobody chose, and unticking it would have to clear its siblings, which is not what any checkbox does.

Which is why a multiple filter owes the reader two exits in its place: a tag for each active filter, whose ✕ removes its own, and one "Clear filters" that removes them all. Dropping the "All" box without adding those leaves the reader inside a filter with no way out, which is the trap the box was covering.

Two sites built with this library filtered the same question two ways — one with radios and an "All" chip, one with checkboxes and none. Each is defensible on its own page. Together they teach one reader two grammars for one question, which is what this page exists to stop.

A label with a count is written once

Options carry their counts, because without them "Orchestra" and "Other" look like two choices of the same size when they are 115 and 7 — and the reader who picks the small one thinks they are narrowing a long list and finds almost nothing.

The shape of that label is one function, called by every control that shows the set: select, chip group, nav, tag. Two shapes for the same fact read as two different facts, and a set of controls is exactly where the second shape gets written.

const withCount = (label, n) => `${label} (${n})`;

withCount('Orchestra', 115);  //  Orchestra (115)  — the select option
withCount('Orchestra', 115);  //  Orchestra (115)  — the chip
withCount('Orchestra', 115);  //  Orchestra (115)  — the tag, the nav entry

And the number goes inside the label, never beside it. The label is the accessible name: put the count in a badge next to the chip and the option is named "Orchestra" while the 115 is announced as a number belonging to nothing. An option cannot hold a badge at all, so the control that can and the control that cannot would disagree by construction.

<!-- Two shapes for the same fact: they read as two different facts. -->
<option value="orchestra">Orchestra: 115</option>
<label class="chip" for="k-orchestra">Orchestra (115)</label>

<!-- And a number outside the label is a number with nothing attached to it:
     the option is named "Orchestra" and the 115 is announced on its own. -->
<label class="chip" for="k-other">Other</label>
<span class="badge">7</span>

A coloured edge has no radius

When a box carries a bar of colour down one side to say what kind of box it is, that side is square. A 3px stroke bent around a 12px corner stops reading as the edge of the panel and starts reading as a stripe laid on top of it, and the two are a different object.

This is what alert, validation summary and toast already do, and it is the rule whenever a card, a row or a panel is marked the same way. The other three corners keep the radius.

Which leaves the half that actually costs the time: the two overrides go after the shorthand. border-radius is a shorthand for all four corners, so written below them it puts back exactly what they took off — and nothing anywhere reports it. The rule looks right, the corners are round, and the bar bends.

.card-flagged {
  border-radius: var(--radius-box);
  border-inline-start: 3px solid var(--color-primary);

  /* After the shorthand. Before it, these two are silently overwritten. */
  border-start-start-radius: 0;
  border-end-start-radius: 0;
}
.card-flagged {
  /* Nothing here survives. The shorthand below re-rounds both corners and
     no build, no linter and no browser reports it. */
  border-start-start-radius: 0;
  border-end-start-radius: 0;

  border-radius: var(--radius-box);
  border-inline-start: 3px solid var(--color-primary);
}

There is a second correct answer, and alert is the one that takes it: never write the shorthand. Four longhand corners, two of them zero, has no order to get wrong, and it is the better shape as soon as the corners differ from each other anyway.

A heading never outweighs the heading above it

An h1 set light and an h2 set bold below it inverts the outline the markup declares, and a reader scanning the page follows the weight, not the tag. If the light setting is the one you want, it belongs to the h1 first and everything under it follows.

Size and weight are one decision taken twice. Pick the pair for the top of the page, then step both down together.

A link that leaves the site says so

target="_blank" with rel="noopener", and a mark the reader can see before pressing it. A reader who followed a source link and lost the listing they had filtered has lost their work, and nothing on the page warned them.

Add rel="nofollow" as well when the link is user-supplied or comes out of a feed you do not control.

The third time, it is a component

A shape drawn twice is a coincidence. Drawn three times it is a component, and it stops being drawn by hand. The failure this prevents is not duplication, it is divergence: a badge for a metro line came out square in the header, round under the page title and a pill in the list, on one site, in one afternoon, because each was written where it was needed.

It does not follow that it belongs here. A component belongs in this library when it is about a kind of thing — a badge, a field, a list row. It stays in the project when it is about that project's subject: a badge that knows the colours of a transport network is the network's knowledge, not the library's. Keep it local, give it a name, and give it one file.

A field at the top of a long list scrolls away, and with it every signal that the page is showing a subset. Two things fix it, and the second one matters more than the first: keep the field reachable, and put the count and the active filters immediately above the results they produced.

Not in a band of their own with space around them. Attached to the list, as one block, so that "127 results" and the first row are read together.

Nothing found and nothing loaded are different states

An empty list because the filter matched nothing, an empty list because a source did not answer, and an empty list because there is genuinely nothing today are three sentences, not one blank panel. Use empty state and say which one it is, because the reader's next action is different in each case.