Skip to content
RU
Scale
Density
Theme
Accent

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.

The component
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.

Example
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.

Example
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.

In context
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

In context
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.

The class on an <a> gives exactly the same button, with no underline.

Example
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

Example
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

Example
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

Example
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

Example
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

Example
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.

In context
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.

In context
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 override background directly — 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-hover moves away from the colour of the label. A mirrored "lighter in the dark theme" drops the label's contrast to 2.61.

API

Classes
NameWhat it does
class
inst-btnThe base one. On a <button> or an <a>. Always required
inst-btn-groupThe container of a group: it removes the neighbour's border and rounds only the ends
inst-btn-stackThe 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-labelThe label inside .inst-btn-stack. Quieter and smaller than the value
inst-btn-valueThe value inside .inst-btn-stack. A long one is truncated: this is an identifier, and wrapping it is not allowed
inst-btn--outlineAn outline: a quiet border, with no fill. The step between ghost and soft
modifier
inst-btn--primaryA solid fill in the accent. One principal action per screen
inst-btn--softA muted fill, with no border. A control inside a group: a toolbar, a row of icons, a selection chip
inst-btn--stackedTwo lines: a quiet label above, the value below. For a control whose point is the value (repository, branch, environment)
inst-btn--fieldThe 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--ghostNo border and no fill. For secondary actions in a dense row
inst-btn--dangerA 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--xsThe 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--smThe height from --control-h-sm, the size --text-xs
inst-btn--lgThe height from --control-h-lg, the size --text-md
inst-btn--iconSquare, an icon only. It calls for an aria-label
Attributes
NameValueWhat it does
attribute
typebutton · submitRequired. Without it a button inside a form will submit it
aria-busytrueThe 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-tonerunning · ok · warn · error · neutralThe tone of the label from the kit's shared vocabulary. Set on the button itself
Tokens
NameValueWhere from
variable
--btn-fgThe colour of the label--text-primarythe application sets it
--btn-bgThe fill. Hover changes only this--surface-raisedthe application sets it
--btn-bg-hoverThe fill under the cursor--surface-hoverthe application sets it
--btn-bg-activeThe fill on a press--surface-activethe application sets it
--btn-borderThe border. A hairline: what identifies a button is the label rather than the outline--borderthe 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 fillnonethe application sets it
token
--control-h-mdThe height. Retuned by density32pxbase +14: density · scale
--control-pad-mdThe horizontal padding. Derived from the height through --control-ratio-md12pxone across every cell
--radius-mdThe rounding of a free-standing control8pxbase +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 label8pxbase +14: density · scale
--text-mutedThe colour of the icon: it is quieter than the label it accompanieslight-dark(var(--n-8), var(--n-6))one across every cell
--text-smThe size of the label14pxbase +4: scale
--weight-mediumThe weight of the label600one across every cell
--size-spinnerThe ring of busyness14pxbase +14: density · scale
--dur-1The duration of the fill transition80msone across every cell

Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument