busy-office-ui
Menu

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

← this cluster is data-density="compact" regardless of the global setting

Row actions — small ghosts in a table cell

Invoice #AmountActions
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

Approved
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

ClassRole
.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 on aria-busy) — CSS handles the rest.
  • Icon-only buttons (--icon) require an aria-label.
  • Icon + text needs no modifier — a .bo-icon beside text composes for free; keep the icon aria-hidden="true".
  • .bo-btn-group joins 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)
TextonLightDark
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
↑↓ move Enter open Esc close