Toggle

States

Accent variant

Ghost variant

Sizes

Toolbar pattern


  
/*
 * Toggle — element (interactive)
 *
 * A button that retains a pressed state. Built on native <button> with
 * aria-pressed. The aria-pressed attribute is the source of truth; CSS
 * styles match against [aria-pressed="true"]. Authors flip the
 * attribute (typically via small inline JS).
 *
 *   <button type="button" class="toggle" aria-pressed="false">Bold</button>
 *   <button type="button" class="toggle" aria-pressed="true">Italic</button>
 */

@layer malevich.components {
  .toggle {
    /* Reset native button */
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    background: transparent;
    color: inherit;
    cursor: pointer;
    user-select: none;

    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-gap-elements-s);

    padding-block: var(--space-inset-element-s);
    padding-inline: var(--space-inset-element-m);
    min-block-size: var(--size-control-m);

    color: var(--color-ink-strong);
    background-color: transparent;
    border: var(--border-width-hairline) solid var(--color-border-default);
    border-radius: var(--radius-button);

    font-family: var(--font-body-support-family);
    font-size: var(--font-body-support-size);
    font-weight: var(--font-body-support-weight);
    line-height: var(--font-body-support-line-height);
    letter-spacing: var(--font-body-support-letter-spacing);

    transition:
      background-color var(--motion-fast) var(--motion-easing-default),
      color var(--motion-fast) var(--motion-easing-default),
      border-color var(--motion-fast) var(--motion-easing-default);
  }

  .toggle:hover:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--color-surface-canvas);
  }

  .toggle:focus-visible {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-hairline);
  }

  .toggle:disabled,
  .toggle[aria-disabled="true"] {
    cursor: not-allowed;
    opacity: 0.5;
  }

  /* Pressed state */
  .toggle[aria-pressed="true"] {
    background-color: var(--color-ink-strong);
    color: var(--color-ink-inverse);
    border-color: var(--color-ink-strong);
  }

  .toggle[aria-pressed="true"]:hover:not(:disabled):not([aria-disabled="true"]) {
    opacity: 0.9;
  }

  /* Sizes */

  .toggle.-s {
    padding-block: var(--space-inset-element-s);
    padding-inline: var(--space-inset-element-s);
    min-block-size: var(--size-control-s);
    font-family: var(--font-caption-family);
    font-size: var(--font-caption-size);
    font-weight: var(--font-caption-weight);
    line-height: var(--font-caption-line-height);
    letter-spacing: var(--font-caption-letter-spacing);
  }

  .toggle.-l {
    padding-block: var(--space-inset-element-m);
    padding-inline: var(--space-inset-element-l);
    min-block-size: var(--size-control-l);
    font-family: var(--font-body-regular-family);
    font-size: var(--font-body-regular-size);
    font-weight: var(--font-body-regular-weight);
    line-height: var(--font-body-regular-line-height);
    letter-spacing: var(--font-body-regular-letter-spacing);
  }

  /* Variants */

  .toggle.-accent[aria-pressed="true"] {
    background-color: var(--color-accent);
    color: var(--color-ink-inverse);
    border-color: var(--color-accent);
  }

  .toggle.-ghost {
    border-color: transparent;
  }

  .toggle.-ghost[aria-pressed="true"] {
    background-color: var(--color-surface-canvas);
    color: var(--color-ink-strong);
    border-color: transparent;
  }
}
// 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.