Actions
Button
An action the user starts themselves. By default a button is quiet: a screen
where everything is primary has no primary at all.
Markup
<button class="inst-btn inst-btn--primary" type="button">Run</button>
<button class="inst-btn" type="button">Cancel</button>
<button class="inst-btn inst-btn--soft" type="button">Filter</button>
<button class="inst-btn inst-btn--ghost" type="button">Details</button>
<button class="inst-btn inst-btn--danger" type="button">Delete</button>
Contract
| What | Required | Why |
|---|---|---|
.inst-btn |
yes | The base class. On a <button> or on an <a> |
type on a <button> |
yes | Without it a button inside a form has type="submit" by default and submits the form — including one that was merely showing a hint |
An <a> for going to an address |
yes | A button leading to another page breaks the middle click, "open in a new tab" and copying the address. The class on a link gives exactly the same button |
aria-busy="true" while it works |
yes, if the button waits | It is spoken, and the label stays in place |
disabled |
the native one | Transparency does not drop the label below the threshold: it is read together with the background |
A roving tabindex |
no | Tab, Enter and Space come entirely from a native <button>; instrument.js intercepts nothing here |
aria-label on inst-btn--icon |
yes | A button with no accessible name is a button there is nothing to call |
A <button> rather than an <input> for the look |
yes | Substituting a field for a button is a lie to a screen reader about being able to type here, and a broken keyboard: a field does not open on space |
:focus-visible — the ring appears from the keyboard and does not appear from
the mouse. In a group the focused button rises by z-index so that its
neighbour does not cut the ring off.
Every variant's label holds 4.5:1 in five themes, and the primary fill is
capped in lightness (0.545 for the default petrol). The target is 32px at md
and 24px at sm; compact takes sm to 22px, below the 24px of WCAG
2.5.8 — hence compact is for dense tabular views, not the main navigation.
Variants
A variant is two lines: it changes only the inner variables rather than rewriting the rule.
Markup
<button class="inst-btn" type="button">Default</button>
<button class="inst-btn inst-btn--primary" type="button">Primary</button>
<button class="inst-btn inst-btn--soft" type="button">Soft</button>
<button class="inst-btn inst-btn--outline" type="button">Outline</button>
<button class="inst-btn inst-btn--ghost" type="button">Ghost</button>
<button class="inst-btn inst-btn--danger" type="button">Danger</button>
| Variant | When |
|---|---|
| no modifier | The default is recessed: a drop in lightness, with neither border nor shadow. The depth is counted from the ground, so the step is the same on a page, in a panel and in a modal |
inst-btn--primary |
The principal action of the screen. One per screen |
inst-btn--soft |
A control inside a group: a muted fill, with no border |
inst-btn--outline |
An outline: a quiet border, with no fill. An action that has to look pressable without laying claim to attention |
inst-btn--ghost |
Third-rank: neither border nor fill — text alone |
inst-btn--danger |
An irreversible or destructive action. A tone rather than a weight: it goes over any of the four and paints the label without adding a border |
data-tone |
The tone of the label from the shared vocabulary: running for an accented action with no chrome, ok, warn, error, neutral. It goes over any weight except --primary |
The ladder of weights
Weight answers "how loudly does the button ask to be pressed", and the answer is measurable: presence is area weighted by the step in lightness against the ground.
| Weight | light | dark |
|---|---|---|
--ghost |
0 | 0 |
--outline |
0.007 | 0.007 |
--soft |
0.017 | 0.022 |
| default | 0.046 | 0.048 |
--primary |
0.47 | 0.17 |
Monotone in all five themes, neighbouring steps no closer than 0.022 — checked
by cmd/contrast as "ladder: soft against default". The outline's border is
decorative, not load-bearing.
Tone
A tone is the kit's shared vocabulary, the same one the badge, the dot, the
meter and the banner use. A button reads --tone-ink from it for the label and
--tone-bg for hover.
Markup
<button class="inst-btn inst-btn--ghost" type="button" data-tone="running">Add context</button>
<button class="inst-btn" type="button" data-tone="ok">Accept</button>
<button class="inst-btn" type="button" data-tone="error">Delete</button>
<button class="inst-btn inst-btn--danger" type="button">Delete</button>
inst-btn--danger is the tone error written as a class — a destructive action
occurs often enough to deserve the short spelling.
A ghost with the tone running is the action-as-a-link: "add context", "add a
row", "create the first object" — a press invited without arguing with the
principal button.
| What | Why |
|---|---|
| The tone is set on the button itself | --tone-ink is inherited, and a rule that merely read the variable would paint a button inside an emergency banner by itself. A tone is declared rather than picked up |
--primary takes no tone |
The label there stands on a fill in the accent and holds 4.5:1 only in white. A red or a green on that fill gives 1.5–2.0, so the combination is disarmed explicitly |
| A tone does not replace a weight | Weight answers how loudly the button asks to be pressed; tone answers what kind of action it is. The questions differ, so the axes differ |
A switch of a value
There is a control whose point is the value, with the label only saying what the value is of: the current repository, branch, environment, account. "master" by itself means nothing, and "Current branch" cannot be removed.
Markup
<button class="inst-btn inst-btn--stacked" type="button" style="inline-size:15rem">
<svg class="inst-icon" aria-hidden="true"><use href="#i-book"/></svg>
<span class="inst-btn-stack">
<span class="inst-btn-label">Current repository</span>
<span class="inst-btn-value">instrument-ui-kit</span>
</span>
<svg class="inst-icon inst-icon--sm" aria-hidden="true"><use href="#i-chevron"/></svg>
</button>
What becomes a column is the text, so .inst-btn-stack is required: a
control like this carries a leading icon and a chevron, and stacking the whole
button would put them above and below instead. Two lines are a property of this
variant rather than permission to wrap any button.
A button in the clothes of a field
Markup
<button class="inst-btn inst-btn--field" type="button" style="inline-size:18rem">
<svg class="inst-icon" aria-hidden="true"><use href="#i-list"/></svg>
Choose a file or a command
</button>
It opens a palette or a list but stays a button. The border is load-bearing here: it is the control.
As a link
The class on an <a> gives exactly the same button, with no underline.
Markup
<a class="inst-btn" href="#button">Open the report</a>
<a class="inst-btn inst-btn--primary" href="#button">Go to the run</a>
<button class="inst-btn" type="button">Run</button>
Sizes
Markup
<button class="inst-btn inst-btn--xs" type="button">xs</button>
<button class="inst-btn inst-btn--sm" type="button">sm</button>
<button class="inst-btn" type="button">md</button>
<button class="inst-btn inst-btn--lg" type="button">lg</button>
The height comes from the role tier (--control-h-*), so a button and a
field of the same size cannot part. Density retunes all
three at once.
| Size | Height | Padding | Type size |
|---|---|---|---|
inst-btn--xs |
--control-h-xs |
--space-3 |
--text-2xs |
inst-btn--sm |
--control-h-sm |
--control-pad-sm |
--text-xs |
| default | --control-h-md |
--control-pad-md |
--text-sm |
inst-btn--lg |
--control-h-lg |
--control-pad-lg |
--text-md |
xs is the odd one: its box is the badge's rather than the
control tier's, because below sm the neighbour in a row is a chip and not a
button. It is for a control that belongs to something else — the icons under an
answer, the session controls under a composer. Its
tap area stays --tap-min through a pseudo-element, so the drawing shrinks and
the target does not — which means the row owes it --space-3: at compact the
box is 18px, and a narrower gap makes two enlarged areas overlap. A primary
standing on its own stays sm.
States
Markup
<button class="inst-btn" type="button">Ordinary</button>
<button class="inst-btn" type="button" disabled>Unavailable</button>
<button class="inst-btn inst-btn--primary" type="button" aria-busy="true">Run</button>
| State | How it is set | What happens |
|---|---|---|
| hover | :hover:not(:disabled) |
Only --btn-bg changes |
| press | :active:not(:disabled) |
--btn-bg + scale(0.985) — a press is to be felt rather than examined |
| focus | :focus-visible |
A ring with a 1px gap, with no radius of its own |
| unavailable | disabled |
An opacity of 0.5 rather than a grey fill: the button keeps its identity, and it shows which action is unavailable |
| busy | aria-busy="true" |
The label dims and a ring takes its place. See below |
Busyness
Markup
<button class="inst-btn inst-btn--primary" type="button" aria-busy="true">Run</button>
<button class="inst-btn" type="button" aria-busy="true">Cancel</button>
<button class="inst-btn inst-btn--icon" type="button" aria-busy="true" aria-label="Refresh">
<svg class="inst-icon" aria-hidden="true"><use href="#i-refresh"/></svg>
</button>
The label is dimmed rather than removed, so the width holds and a screen reader
still reads it. The button keeps its focus — disabled would throw it out of
the tab order under the hands of whoever came by keyboard — and only the mouse
is taken away; guarding against a second press belongs to the handler.
With icon
Markup
<button class="inst-btn" type="button">
<svg class="inst-icon" aria-hidden="true"><use href="#i-refresh"/></svg>
Refresh
</button>
<button class="inst-btn inst-btn--icon" type="button" aria-label="Refresh">
<svg class="inst-icon" aria-hidden="true"><use href="#i-refresh"/></svg>
</button>
An icon button is square: the width comes from the same control height, so it holds at all three sizes and every density.
A button with no text has to carry an aria-label. The icon is then
aria-hidden="true" — otherwise a screen reader reads it twice or does not
read it at all.
Composition
A group
Markup
<div class="inst-btn-group">
<button class="inst-btn inst-btn--sm" type="button">Left</button>
<button class="inst-btn inst-btn--sm" type="button">Centre</button>
<button class="inst-btn inst-btn--sm" type="button">Right</button>
</div>
Several different actions standing flush — see the section index for how a group differs from a segmented control and a chip.
Patterns
The action bar of a form
The principal action stands at the outer edge of the bar. Here the bar is pushed to the end of the line, so the principal one goes last with cancel before it; aligned to the start the order is the reverse — see the form.
Markup
<form class="inst-panel">
<div class="inst-panel-body">
<div class="inst-field">
<label class="inst-label" for="p-name">Run name</label>
<input class="inst-input" id="p-name" type="text" value="nightly-4127">
</div>
<div class="inst-form-actions inst-form-actions--end">
<button class="inst-btn" type="button">Cancel</button>
<button class="inst-btn inst-btn--primary" type="submit">Save</button>
</div>
</div>
</form>
The actions of a table row
A dense row takes ghost and sm: a border on every button in a table would
turn the column into a grid. A destructive action keeps its border — it is the
one thing in the row that has to be noticeable.
Markup
<div class="inst-cluster">
<button class="inst-btn inst-btn--sm inst-btn--ghost" type="button">Open</button>
<button class="inst-btn inst-btn--sm inst-btn--ghost" type="button">Retry</button>
<button class="inst-btn inst-btn--sm inst-btn--danger" type="button">Delete</button>
</div>
Customization
A variant of your own is two lines. What is overridden are the variables rather than the rule.
.my-btn--accent {
--btn-fg: var(--accent-on);
--btn-bg: var(--accent-solid);
}
Two limits that are easier to break than to notice:
- Hover changes only
--btn-bg. Do not overridebackgrounddirectly — the variable will stop being read, and the variant will lose its hover. - A fill under a white label cannot be lighter than the bound for its hue in
OKLCH — 0.545 at 215°, 0.568 at 250°, 0.576 at 275°, 0.580 at 45°. Above
that white text stops holding 4.5:1. For the same reason
--btn-bg-hovermoves away from the colour of the label. A mirrored "lighter in the dark theme" drops the label's contrast to 2.61.
API
| Name | What it does |
|---|---|
| class | |
inst-btn | The base one. On a <button> or an <a>. Always required |
inst-btn-group | The container of a group: it removes the neighbour's border and rounds only the ends |
inst-btn-stack | The column of text inside --stacked. Required: what becomes a column is the text rather than the button, otherwise the icon travels above the label |
inst-btn-label | The label inside .inst-btn-stack. Quieter and smaller than the value |
inst-btn-value | The value inside .inst-btn-stack. A long one is truncated: this is an identifier, and wrapping it is not allowed |
inst-btn--outline | An outline: a quiet border, with no fill. The step between ghost and soft |
| modifier | |
inst-btn--primary | A solid fill in the accent. One principal action per screen |
inst-btn--soft | A muted fill, with no border. A control inside a group: a toolbar, a row of icons, a selection chip |
inst-btn--stacked | Two lines: a quiet label above, the value below. For a control whose point is the value (repository, branch, environment) |
inst-btn--field | The look of a recess, the behaviour of a button. It opens a palette or a list; substituting a real <input> is not allowed — that is a lie to a screen reader |
inst-btn--ghost | No border and no fill. For secondary actions in a dense row |
inst-btn--danger | A destructive action: the tone of an error in the label. This is a TONE over a weight rather than a fifth weight — it combines with any of them |
inst-btn--xs | The badge's box: --control-h-xs, --space-3, --radius-xs. For a control belonging to something else. The tap area stays --tap-min through a ::before |
inst-btn--sm | The height from --control-h-sm, the size --text-xs |
inst-btn--lg | The height from --control-h-lg, the size --text-md |
inst-btn--icon | Square, an icon only. It calls for an aria-label |
| Name | Value | What it does |
|---|---|---|
| attribute | ||
type | button · submit | Required. Without it a button inside a form will submit it |
aria-busy | true | The action has started: the label dims and a ring turns in its place. The width does not change |
aria-label | — | Required on inst-btn--icon: a button with no text has no accessible name |
disabled | — | Unavailable. Not applied for busyness — it throws the button out of the keyboard traversal |
data-tone | running · ok · warn · error · neutral | The tone of the label from the kit's shared vocabulary. Set on the button itself |
| Name | Value | Where from |
|---|---|---|
| variable | ||
--btn-fgThe colour of the label | --text-primary | the application sets it |
--btn-bgThe fill. Hover changes only this | --surface-raised | the application sets it |
--btn-bg-hoverThe fill under the cursor | --surface-hover | the application sets it |
--btn-bg-activeThe fill on a press | --surface-active | the application sets it |
--btn-borderThe border. A hairline: what identifies a button is the label rather than the outline | --border | the application sets it |
--btn-shadowThe default has no shadow — the depth is carried by the recess. It is set on --primary: there, together with the edge, it gives thickness to a solid fill | none | the application sets it |
| token | ||
--control-h-mdThe height. Retuned by density | 32px | base +14: density · scale |
--control-pad-mdThe horizontal padding. Derived from the height through --control-ratio-md | 12px | one across every cell |
--radius-mdThe rounding of a free-standing control | 8px | base +4: scale |
--shadow-controlA shadow of one pixel. Not "it floats above" but "it has thickness" | 0 1px 2px -1px var(--shadow-color-far), 0 1px 1px -1px var(--shadow-color-near) | one across every cell |
--gap-inlineThe gap between the icon and the label | 8px | base +14: density · scale |
--text-mutedThe colour of the icon: it is quieter than the label it accompanies | light-dark(var(--n-8), var(--n-6)) | one across every cell |
--text-smThe size of the label | 14px | base +4: scale |
--weight-mediumThe weight of the label | 600 | one across every cell |
--size-spinnerThe ring of busyness | 14px | base +14: density · scale |
--dur-1The duration of the fill transition | 80ms | one across every cell |
Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument