Button
The action primitive: variants for emphasis, a small size for dense toolbars, and disabled/loading states. Not for picking one of several views — that is a segmented control — or for a pill the reader clicks to filter, which is a chip. Which of separate buttons, a group, a segmented control or a bar to use is below.
Variants
View code
<button class="bo-btn">Primary</button>
<button class="bo-btn bo-btn--secondary">Secondary</button>
<button class="bo-btn bo-btn--ghost">Ghost</button>
<button class="bo-btn bo-btn--danger">Danger</button>
<button class="bo-btn bo-btn--danger-ghost">Reject</button>
<button class="bo-btn bo-btn--icon" aria-label="Settings">⚙</button> --danger-ghost is a destructive action
that sits beside a solid primary without competing with it (Approve / Reject).
Sizes
View code
<button class="bo-btn">Default</button>
<button class="bo-btn bo-btn--sm">Small</button>
<button class="bo-btn bo-btn--sm bo-btn--ghost bo-btn--icon" aria-label="Move up">↑</button> --sm is a 24px control (the WCAG target
floor) for inline / row actions.
States
View code
<button class="bo-btn" disabled>Disabled</button>
<button class="bo-btn" data-loading="true">Saving…</button> Icon only, text only, icon + text
View code
<button class="bo-btn"><span class="bo-icon bo-icon--check-circle" aria-hidden="true"></span>Approve</button>
<button class="bo-btn bo-btn--secondary"><span class="bo-icon bo-icon--truck" aria-hidden="true"></span>Ship</button>
<button class="bo-btn bo-btn--icon" aria-label="Settings"><span class="bo-icon bo-icon--grid" aria-hidden="true"></span></button>
<button class="bo-btn">Text only</button> One primitive, three shapes — no new class for
any of them: a plain .bo-icon beside text composes because
.bo-btn is already inline-flex with a gap.
Every icon here carries aria-hidden="true", per
the icon component's own
contract.
Per-region density override
data-density="compact" regardless of the global setting Row actions — small ghosts in a table cell
| Invoice # | Amount | Actions |
|---|---|---|
| INV-10234 | $4,208.00 |
What --sm --ghost --icon exists for:
per-row actions that don't shout. Each icon button names its ROW in the
aria-label ("Edit INV-10234", never just "Edit"); for more than
two actions, collapse into a row dropdown.
Button group — a toolbar, not a choice
.bo-btn-group visually joins
independent buttons into one control — every button stays its own
click target. Not .bo-segmented: that is
radios, for picking exactly one of a small set (My approvals / Team
approvals); a button group is a toolbar where every action fires on
its own. Each button keeps its own border, focus ring and disabled
state — only the shared edge between two buttons collapses to one
line, so :focus-visible still outlines a single button,
never the whole group. Not for a decision's answers or
a form's Post · Cancel either: joined, they read as one tool and hide
the difference in weight.
Action bar — --bar, the RF exception row
…the scan screen above it…
--bar stretches the group to a
full-width row of equal targets — a gloved thumb aims
at a position, so positions must not shift when labels differ (a long
label WRAPS taller inside its slot instead of breaking the grid — the
first draft ellipsized, and the layout sweep failed it under WCAG
1.4.12: a label is content, and content never clips).
Height is the density system's job: data-density="spacious"
is the glove tier, as everywhere. The buttons are
intent-named — Back, Skip item, Report short — never
the F5/F6 numbers green-screen conversions carry forward: a number the
user must translate is a label that failed. Dock it with one consumer
line (position: sticky; inset-block-end: 0) — where it
docks belongs to the screen, not the component. When the screen's main
action is a press — Approve on a phone, Submit count on a scanner — it
takes the last slot as the bar's one solid button. Keep a handheld's bar
to three slots, so every label keeps a slot wide enough to read at a
glance.
Which one: separate buttons, a group, a segmented control or a bar
Separate buttons are the default, for actions that
differ in weight or consequence: one solid primary, its secondaries, and
a --danger-ghost beside them. A decision's answers stay
separate, so Reject is never one seam from Approve. Not
for two to four same-weight tools on one object; join those.
A group (.bo-btn-group) joins two to four
same-weight actions on one object, each firing on its own: Copy link ·
Duplicate · More ▾. A group never wraps, so on a phone keep it to what
fits and put the rest behind More ▾. The same join carries toggles that
can be on together, like Bold and Italic: buttons with
aria-pressed. Not for picking one of
several views.
A segmented control (.bo-segmented)
keeps exactly one of two to five views on: My approvals · Team
approvals. It is built on radios, so the arrow keys move the choice.
Not for commands — a segment that saves, posts or
deletes is a button. Past five options, use a select.
A bar (.bo-btn-group--bar) is a
handheld's full-width row of equal slots under a single-task screen:
Back · Skip item · Report short. On a phone or scanner a decision's
answers go here too. Not for desktop toolbars or form
footers, where equal widths spend space a pointer does not need.
Edges and seams. A secondary member's border measures 1.41:1 against the page in light and 1.90:1 in dark, and the seam between two secondaries 1.47:1 and 1.70:1 against the member's fill — below the 3:1 that WCAG 1.4.11 asks of a boundary that identifies a control. A group and a bar do not ask the line to do that job: every member carries its own label (17.74:1 and 16.15:1 on a secondary), and a bar's equal slots centre each label on its own target, which is where a thumb aims. Where the boundary carries meaning, the fill carries it — the solid primary.
Separate buttons — a decision's answers
View code
<div class="bo-cluster">
<button class="bo-btn" type="button">Approve</button>
<button class="bo-btn bo-btn--secondary" type="button">Hold</button>
<button class="bo-btn bo-btn--danger-ghost" type="button">Reject</button>
</div> Three answers to one question, in three weights, with space between them. Joined into a group they would read as one tool, and Reject would sit one seam from Approve.
Toggle buttons — several on at once
View code
<div class="bo-btn-group" role="group" aria-label="Text style">
<button class="bo-btn bo-btn--secondary" type="button" aria-pressed="true"><strong>B</strong><span class="bo-visually-hidden"> Bold</span></button>
<button class="bo-btn bo-btn--secondary" type="button" aria-pressed="false"><em>I</em><span class="bo-visually-hidden"> Italic</span></button>
</div> aria-pressed carries the state
to assistive tech; under forced colours the pressed button takes the
system's own Highlight pair. In normal colours the pressed fill differs
from the unpressed one by colour alone, which is a known gap (roadmap
392.2), so do not rely on it as the only cue yet. Use a toggle when options combine (Bold and Italic can
both be on); use a segmented control when exactly one is on.
Segmented control — one view of several
View code
<div class="bo-segmented" role="group" aria-label="Approval scope">
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="btn-guide-scope" id="btn-guide-mine" checked>
<label class="bo-segmented__option" for="btn-guide-mine">My approvals</label>
<input class="bo-segmented__input bo-visually-hidden" type="radio" name="btn-guide-scope" id="btn-guide-team">
<label class="bo-segmented__option" for="btn-guide-team">Team approvals</label>
</div> One of these is always on, and picking the other changes what the screen shows. Radios underneath: Tab reaches the group once, and the arrow keys move the choice. Not for actions — see segmented control.
One shape — a pill is never a command button
View code
<div class="bo-cluster" id="g-shape">
<span class="bo-badge bo-badge--success">Approved</span>
<button class="bo-btn bo-btn--sm bo-btn--secondary" type="button">Release</button>
</div> There is no capsule button. Here a pill is a status, a token or a link to one — this badge, a filter chip, a tag and its remove control — and the rounded rectangle is what says "this runs a command". At this size the two share height, text size and weight, so the corners are what tells them apart. Not for a pill the reader clicks to filter a list: that is a chip.
Quick reference generated
| Class | Role |
|---|---|
| .bo-btn | block (root) |
| .bo-btn--danger | variant: danger |
| .bo-btn--danger-ghost | variant: danger-ghost |
| .bo-btn--ghost | variant: ghost |
| .bo-btn--icon | variant: icon |
| .bo-btn--secondary | variant: secondary |
| .bo-btn--sm | variant: sm |
| .bo-btn-group | block (root) |
| .bo-btn-group--bar | variant: bar |
State attributes: data-loadingaria-busyaria-disabledaria-pressed
API generated from CSS
| Blocks | bo-btnbo-btn-group |
|---|---|
| Variants | bo-btn--dangerbo-btn--danger-ghostbo-btn--ghostbo-btn--iconbo-btn--secondarybo-btn--smbo-btn-group--bar |
| data-* hooks | data-loading="true" |
| ARIA styled | aria-busyaria-disabledaria-pressed |
| Component tokens | --bo-btn-bg--bo-btn-bg-hover--bo-btn-border--bo-btn-fg--bo-btn-height--bo-btn-padding-x |
| JS required | None — CSS-only. |
- Loading state: flip
data-loading="true"(or rely onaria-busy) — CSS handles the rest. - Icon-only buttons (
--icon) require anaria-label. - Icon + text needs no modifier — a
.bo-iconbeside text composes for free; keep the iconaria-hidden="true". .bo-btn-groupjoins a row of buttons into one toolbar — independent actions, not a single-choice picker (that is.bo-segmented).- Labels do NOT wrap by default, and that is deliberate: a button is a target, and a target whose height depends on its label makes rows jump. A label wraps only where the button is forced into a slot it cannot escape —
.bo-btn-group--bar(equal shares) and.bo-form-actions(a sticky bar with no line to fall onto). Everywhere else the container decides the layout, so give a long label a container that fits it.
Contrast — 31 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| text-inverse | accent-solid | 5.47 | 5.47 |
| text-inverse | danger-solid | 4.83 | 4.83 |
| bg-surface | text-primary | 17.74 | 16.15 |
| accent-text | accent-subtle | 7.27 | 8.38 |
| danger-text | danger-subtle | 5.91 | 8.06 |
| danger-text | bg-surface | 6.47 | 8.89 |
| danger-text | bg-canvas | 6.19 | 9.96 |
| success-text | bg-surface | 5.02 | 12.02 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| accent-text | bg-selected | 7.27 | 8.38 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| border-control | bg-surface | 4.83 | 6.65 |
| accent | bg-selected | 5.25 | 6.66 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| border-control | bg-muted | 4.39 | 5.66 |
| accent | bg-muted | 4.97 | 7.72 |
| warning-strong | bg-muted | 4.56 | 6.69 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
| success-text | bg-muted | 4.56 | 10.23 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | --bo-font-size-xs on --sm; no raw font-size anywhere |
| Colour | 3 / 3 | zero raw colour; variants are local custom props over semantic tokens |
| Spacing | 3 / 3 | the single literal, --sm’s 1.5rem height, carries its reason in the comment directly above it — it is the WCAG target floor, and the height stays a min-height |
| Interaction | 3 / 3 | a native <button>; the loading state is data-loading/aria-busy handled entirely in CSS, so it needs no behavior |
| Content | 3 / 3 | the action primitive every other component defers to — there is no "use X instead of a button", so it is EXEMPT in check:wrong-choice rather than missing the clause |
| Fit | 3 / 3 | the action primitive every other component defers to for its affordances |