Toggle
A button that retains a pressed state. Used for inline on/off controls that visually resemble buttons more than switches — toolbar tools, filter chips, segmented options.
Distinct from:
- Button — single-action, no retained state.
- Switch — on/off setting that takes immediate effect (typically a setting, not a tool).
When to use
- Text editor toolbar: bold, italic, underline.
- Filter chips: "Show archived", "Only mine".
- Single-button view-mode toggles.
For groups where exactly one option is selected, use a Tab. For multiple-select chip groups, use multiple Toggles + custom UX.
Variants
| Variant | Class | Use for |
|---|---|---|
| Default | .toggle |
Bordered, ink-strong when pressed |
| Accent | .toggle.-accent |
Accent fill when pressed |
| Ghost | .toggle.-ghost |
Borderless |
| Small | .toggle.-s |
Compact (caption typography) |
| Large | .toggle.-l |
Larger (body-regular typography) |
Anatomy
<button type="button" class="toggle" aria-pressed="false">Bold</button>
<button type="button" class="toggle" aria-pressed="true">Italic</button>
<!-- Variants -->
<button type="button" class="toggle -accent" aria-pressed="true">Pinned</button>
<button type="button" class="toggle -ghost" aria-pressed="false">Mute</button>
<!-- Sizes -->
<button type="button" class="toggle -s" aria-pressed="false">Filter</button>
<button type="button" class="toggle -l" aria-pressed="false">Section A</button>
The component's runtime (initToggle, auto-registered) flips
aria-pressed between "true" and "false" on click and emits a
malevich:toggle CustomEvent (bubbles).
button.addEventListener("malevich:toggle", (e) => {
console.log("now pressed?", e.detail.pressed);
});
Tokens used
From semantic tier
--color-ink-strong— text default, fill when pressed--color-ink-inverse— text when pressed--color-surface-canvas— hover, ghost-pressed--color-border-default— border default--color-accent— accent variant when pressed, focus ring--space-inset-element-{s,m,l}— padding--space-gap-elements-s— internal gap (icon + label)--size-control-{s,m,l}— height--radius-button— corner radius--border-width-hairline/--border-width-focus--font-action-{s,m,l}-*— typography--motion-fast/--motion-easing-default
Accessibility
The component is a real <button type="button">:
- Keyboard: Enter and Space activate (browser default for
<button>). - Screen readers announce the pressed state via
aria-pressed. - Focus appears via
:focus-visibleoutline. - Disabled buttons skip the click handler and announce as disabled.
Setting aria-pressed to "false" (not omitting it) communicates
"this button is a toggle, currently not pressed." Assistive tech
distinguishes "button" from "toggle button" based on the presence of
aria-pressed.
The runtime auto-populates aria-pressed="false" if the attribute is
missing, so authors don't have to remember.
Edge cases
- Toggle groups (segmented): several toggles in a row. Use a
role="group"wrapper witharia-label. The current Tabs component is a better fit when exactly one option must be selected; Toggle groups are for multi-select. - Toggle as filter that applies async work: dispatch fetch on
malevich:toggle; show pending state via Spinner or by adding adata-loading="true"attribute (consumer-defined). - Toggle with icon only: include an
<svg>(or icon component later) and anaria-labelon the button. Without visible text, the button still needs an accessible name.
Do
- Use a real
<button type="button">. Don't apply.toggleto a<div>. - Set
aria-pressedexplicitly on initial render (the runtime will populate if missing, but explicit is clearer). - Listen for
malevich:toggle(or nativeclick) rather than setting up aMutationObserveron the attribute.
Don't
- Don't use a Toggle when only one option in a group can be active — use Tabs.
- Don't apply Toggle styling to
<a>(link). Pressed-state semantics are a button concern. - Don't manipulate
aria-pressedfrom outside the component without also updating any related state. The attribute is the source of truth.