Buttons
Summary
Actions. The default button is a quiet white chrome; variants
express weight: one primary action per view,
danger for destructive commands, and chrome-free
subtle/link forms for secondary
actions. Buttons join into groups and split buttons, and any
button becomes a dropdown trigger (with an automatic chevron) by
adding soma-dropdown2-trigger.
When to use
| Variant | Use it for |
|---|---|
.soma-button | Any ordinary action. The workhorse. |
.soma-button-primary | The single most important action in the view: Save, Create, Approve. One per view. |
.soma-button-danger | Destructive commands (delete, stop, revoke). Pair with a confirm dialog for irreversible ones. |
.soma-button-subtle | Toolbar and repeated-row actions where full chrome would be noisy. |
.soma-button-link | An action that reads as a sentence — Cancel next to a primary. |
.soma-button-compact | Dense contexts: table rows, toolbars, widget headers. |
Examples
Buttons composed into an action row above content are the Toolbar pattern.
Toggle buttons
A toggle button keeps its on/off state on
aria-pressed: the attribute is simultaneously the
accessible state (screen readers announce "pressed") and the
styling hook (Soma styles [aria-pressed="true"] the
same as .soma-active). There is no class to keep in
sync: your listener flips the attribute and the look follows. Use
aria-pressed for genuine on/off controls (Watch,
Pin); for the selected segment of a view-switching group, use
.soma-active, since that selection is usually
navigation state, not a pressed control.
Loading state
.soma-button-loading marks a button whose action is
in flight: the label is hidden but keeps its box (the width never
jumps), a 14px currentColor spinner is centered over
it (so it matches any variant automatically), and pointer events
are off. The class is visual only; the markup
contract is to set it together with aria-busy="true"
(assistive tech hears the busy state) and disabled
(activation is genuinely blocked; pointer-events only stops the
mouse). Remove all three when the action settles. Under
prefers-reduced-motion the spinner keeps spinning,
just slower — the rotation is the information, same rule as
Spinner.
Dropdown-trigger interplay
Adding soma-dropdown2-trigger to any button does
three things: dropdown2's auto-init
binds the open/close behaviour, aria-controls names
the menu element it opens, and the button grows a chevron
automatically: a CSS ::after fed by the generated
root-level --soma-icon-chevron-down variable, no
markup span needed. -arrowless suppresses the chevron
for icon-only "…" triggers. A split button is a joined
.soma-buttons pair: the -split-main half
performs the default action, the -split-more half is
the trigger (hairline seam between them, and it always needs an
aria-label). Menu markup and the keyboard pattern are
documented on the Dropdowns page.
HTML
The minimal contract — a class on a native <button> (or <a>):
<button class="soma-button soma-button-primary">Save</button>
With an icon, a leading .soma-icon span; icon-only
buttons carry their name in aria-label:
<button class="soma-button">
<span class="soma-icon soma-icon-plus"></span>New project
</button>
<button class="soma-button soma-button-subtle" aria-label="Refresh">
<span class="soma-icon soma-icon-refresh"></span>
</button>
A toggle button; state lives on aria-pressed:
<button class="soma-button" aria-pressed="false">Watch</button>
The loading state. The class never travels alone; pair it with
aria-busy="true" and disabled, and
remove all three together when the action settles:
<button class="soma-button soma-button-primary soma-button-loading"
aria-busy="true" disabled>Save</button>
A segmented group — .soma-buttons joins the
children; .soma-active marks the selected segment:
<div class="soma-buttons">
<button class="soma-button">Years</button>
<button class="soma-button soma-active">Months</button>
<button class="soma-button">Days</button>
</div>
A split button pairs the main action with a joined dropdown trigger:
<div class="soma-buttons">
<button class="soma-button soma-button-split-main">Save</button>
<button class="soma-button soma-button-split-more soma-dropdown2-trigger"
aria-controls="save-menu" aria-label="More save options"></button>
</div>
An icon-only menu trigger, chevron suppressed:
<button class="soma-button soma-dropdown2-trigger soma-dropdown2-trigger-arrowless"
aria-controls="more-menu" aria-label="More actions">
<span class="soma-icon soma-icon-more"></span>
</button>
A link rendered as a button. Since <a>
has no disabled attribute, the disabled form uses
aria-disabled (styled the same):
<a class="soma-button" href="/projects/new">New project</a>
<a class="soma-button" aria-disabled="true" href="/projects/new">New project</a>
CSS classes
| Class | Effect |
|---|---|
.soma-button | Base button. Required. |
.soma-button-primary / -danger / -subtle / -link | Weight variants (see above). |
.soma-button-compact | Smaller padding and type. Combines with any variant. |
.soma-button-loading | Loading state: label hidden (width preserved), centered currentColor spinner, pointer events off. Visual only — always pair with aria-busy="true" + disabled. |
.soma-buttons | Wrapper that joins children into one segmented control. |
.soma-button-split-main / .soma-button-split-more | The two halves of a split button, inside .soma-buttons; a hairline seam separates them so they read as two targets. |
.soma-active / [aria-pressed="true"] | Pressed/selected state: the class for segments, the attribute for toggle buttons (flip it and the styling follows). |
[disabled] / [aria-disabled="true"] | Disabled state — dimmed, no pointer events. The native attribute for buttons, the ARIA one for links. |
.soma-dropdown2-trigger | Turns the button into a menu trigger and adds the chevron glyph automatically. |
.soma-dropdown2-trigger-arrowless | Suppresses the automatic chevron, for icon-only "…" menus. |
JavaScript
None shipped. Buttons are pure CSS; dropdown-trigger behaviour comes from the dropdown2 component's auto-init. The stateful patterns are one consumer listener each.
To wire a toggle button, flip aria-pressed; styling
follows:
const watch = document.getElementById('btn-toggle');
watch.addEventListener('click', () => {
const on = watch.getAttribute('aria-pressed') === 'true';
watch.setAttribute('aria-pressed', String(!on));
});
An exclusive segmented group. One delegated listener moves
.soma-active to the clicked segment (then applies
whatever the selection means):
const seg = document.getElementById('btn-segment');
seg.addEventListener('click', (e) => {
const btn = e.target.closest('.soma-button');
if (!btn) return;
seg.querySelectorAll('.soma-button').forEach((b) =>
b.classList.toggle('soma-active', b === btn));
// …re-render the view for the chosen granularity
});
For the loading state, set the class, aria-busy and
disabled together while the action runs, and remove
all three when it settles:
async function withLoading(btn, action) {
btn.classList.add('soma-button-loading');
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
try {
await action();
} finally {
btn.classList.remove('soma-button-loading');
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
save.addEventListener('click', () => withLoading(save, () => api.save()));
The live examples above, exactly as this page wires them:
// Toggle button — the consumer flips aria-pressed; styling follows.
const watch = document.getElementById('btn-toggle');
watch.addEventListener('click', () => {
const on = watch.getAttribute('aria-pressed') === 'true';
watch.setAttribute('aria-pressed', String(!on));
});
// Segmented group — .soma-active follows the clicked segment.
const seg = document.getElementById('btn-segment');
seg.addEventListener('click', (e) => {
const btn = e.target.closest('.soma-button');
if (!btn) return;
seg.querySelectorAll('.soma-button').forEach((b) =>
b.classList.toggle('soma-active', b === btn));
});
// Loading — class + aria-busy + disabled together, for a pretend 1.5s save.
const save = document.getElementById('btn-loading');
save.addEventListener('click', () => {
save.classList.add('soma-button-loading');
save.setAttribute('aria-busy', 'true');
save.disabled = true;
setTimeout(() => {
save.classList.remove('soma-button-loading');
save.removeAttribute('aria-busy');
save.disabled = false;
}, 1500);
});