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

VariantUse it for
.soma-buttonAny ordinary action. The workhorse.
.soma-button-primaryThe single most important action in the view: Save, Create, Approve. One per view.
.soma-button-dangerDestructive commands (delete, stop, revoke). Pair with a confirm dialog for irreversible ones.
.soma-button-subtleToolbar and repeated-row actions where full chrome would be noisy.
.soma-button-linkAn action that reads as a sentence — Cancel next to a primary.
.soma-button-compactDense contexts: table rows, toolbars, widget headers.

Examples

Variants

One primary per view; danger for destructive commands; subtle and link are chrome-free.

With icons

A leading .soma-icon span inherits currentColor and spaces itself via the button's gap. Icon-only buttons need an aria-label.

Compact

-compact combines with any variant: table rows, toolbars, widget headers.

Disabled

The native disabled attribute — use aria-disabled="true" on links.

Loading

Click Save to see it live:

The demo JS adds .soma-button-loading + aria-busy="true" + disabled for 1.5s. The label hides but keeps its box, so the width never jumps; the spinner takes the variant's text color.

Toggle (aria-pressed)

Click it to flip the live state:

The demo JS flips aria-pressed; the attribute is both the accessible state and the styling hook (same look as .soma-active).

Group

Click a segment to move the live selection:

A .soma-buttons wrapper joins children into one segmented control; .soma-active marks the selected segment (the demo JS moves it).

Split button

Main action + joined dropdown trigger. The menu is live; the chevron half needs an aria-label.

Dropdown trigger

Any button + soma-dropdown2-trigger grows the chevron automatically and opens the menu named in aria-controls.

Arrowless trigger

-arrowless suppresses the automatic chevron, so icon-only "…" menus keep their square footprint.

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

ClassEffect
.soma-buttonBase button. Required.
.soma-button-primary / -danger / -subtle / -linkWeight variants (see above).
.soma-button-compactSmaller padding and type. Combines with any variant.
.soma-button-loadingLoading state: label hidden (width preserved), centered currentColor spinner, pointer events off. Visual only — always pair with aria-busy="true" + disabled.
.soma-buttonsWrapper that joins children into one segmented control.
.soma-button-split-main / .soma-button-split-moreThe 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-triggerTurns the button into a menu trigger and adds the chevron glyph automatically.
.soma-dropdown2-trigger-arrowlessSuppresses 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);
});