// Toggle — runtime enhancement.
//
// Native <button> doesn't carry pressed state on its own. The
// component uses aria-pressed as the source of truth; clicking flips
// the attribute. Idempotent — safe to call repeatedly.
const READY_ATTR = "data-malevich-ready";
/**
* Initialize toggle behavior on an element. Listens for click and
* flips aria-pressed between "true" and "false". If the attribute is
* missing, the element is treated as "false" on first click.
*/
export function initToggle(el: HTMLButtonElement): void {
if (el.getAttribute(READY_ATTR) === "true") return;
if (!el.hasAttribute("aria-pressed")) {
el.setAttribute("aria-pressed", "false");
}
el.addEventListener("click", () => {
if (el.disabled || el.getAttribute("aria-disabled") === "true") return;
const pressed = el.getAttribute("aria-pressed") === "true";
el.setAttribute("aria-pressed", pressed ? "false" : "true");
el.dispatchEvent(
new CustomEvent("malevich:toggle", {
bubbles: true,
detail: { pressed: !pressed },
}),
);
});
el.setAttribute(READY_ATTR, "true");
}
/**
* Initialize all `.toggle` buttons in a root.
*/
export function initToggles(root: ParentNode = document): void {
const toggles = root.querySelectorAll<HTMLButtonElement>("button.toggle");
for (const t of toggles) initToggle(t);
}
# Toggle — AGENTS.md
> Auto-generated from `toggle.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.
## Identity
- **Component:** `toggle`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-19
## Summary
- Text editor toolbar: bold, italic, underline.
## 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) |
## Modifier applicability
Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.
| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `flat` |
| `effect` | `glow`, `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | — |
## Anatomy
```html
<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).
```js
button.addEventListener("malevich:toggle", (e) => {
console.log("now pressed?", e.detail.pressed);
});
```
## Tokens consumed
### 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 contract
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-visible` outline.
- 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.
## Guidance
### Do
- Use a real `<button type="button">`. Don't apply `.toggle` to a
`<div>`.
- Set `aria-pressed` explicitly on initial render (the runtime will
populate if missing, but explicit is clearer).
- Listen for `malevich:toggle` (or native `click`) rather than
setting up a `MutationObserver` on 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-pressed` from outside the component without
also updating any related state. The attribute is the source of
truth.