Switch

States

Variants

Small


  
/*
 * Switch — element (interactive)
 *
 * On/off control built on a native <input type="checkbox">. The native
 * input is visually replaced (not hidden) by a track + thumb rendered
 * via CSS pseudo-elements on the surrounding label. Keyboard focus and
 * state stay on the real input for native accessibility.
 *
 *   <label class="switch">
 *     <input type="checkbox" class="switch__input" />
 *     <span class="switch__track"></span>
 *     <span class="switch__label">Enable notifications</span>
 *   </label>
 */

@layer malevich.components {
  .switch {
    --switch-track-w: calc(var(--size-control-m) * 1.6);
    --switch-track-h: var(--size-control-s);
    --switch-thumb: calc(var(--size-control-s) - var(--space-inset-element-s));

    display: inline-flex;
    align-items: center;
    gap: var(--space-gap-elements-m);
    cursor: pointer;
    user-select: none;

    color: var(--color-ink-strong);
    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);
  }

  /* The native input is visually hidden but accessible. */
  .switch__input {
    position: absolute;
    inline-size: var(--border-width-hairline);
    block-size: var(--border-width-hairline);
    padding: 0;
    margin: calc(-1 * var(--border-width-hairline));
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

  .switch__track {
    position: relative;
    flex-shrink: 0;
    inline-size: var(--switch-track-w);
    block-size: var(--switch-track-h);

    background-color: var(--color-border-strong);
    border-radius: var(--radius-pill);

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

  .switch__track::after {
    content: "";
    position: absolute;
    inset-block-start: 50%;
    inset-inline-start: var(--space-inset-element-s);
    transform: translateY(-50%);

    inline-size: var(--switch-thumb);
    block-size: var(--switch-thumb);

    background-color: var(--color-surface-canvas);
    border-radius: 50%;
    box-shadow: var(--shadow-raised);

    transition: inset-inline-start var(--motion-fast) var(--motion-easing-default);
  }

  /* Checked state */
  .switch__input:checked + .switch__track {
    background-color: var(--color-accent);
  }

  .switch__input:checked + .switch__track::after {
    inset-inline-start: calc(
      var(--switch-track-w) - var(--switch-thumb) - var(--space-inset-element-s)
    );
  }

  /* Focus */
  .switch__input:focus-visible + .switch__track {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-hairline);
  }

  /* Disabled */
  .switch:has(.switch__input:disabled),
  .switch__input:disabled ~ .switch__label {
    cursor: not-allowed;
    opacity: 0.5;
  }

  .switch__label {
    flex: 1;
  }

  /* Variants */

  .switch.-success .switch__input:checked + .switch__track {
    background-color: var(--color-success);
  }

  .switch.-danger .switch__input:checked + .switch__track {
    background-color: var(--color-danger);
  }

  .switch.-s {
    --switch-track-w: var(--size-control-m);
    --switch-track-h: calc(var(--size-control-s) * 0.75);
    --switch-thumb: calc(var(--size-control-s) * 0.5);
  }

  @media (prefers-reduced-motion: reduce) {
    .switch__track,
    .switch__track::after {
      transition: none;
    }
  }
}
This component is pure CSS — no JavaScript required.
# Switch — AGENTS.md

> Auto-generated from `switch.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `switch`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

Use a switch when a setting takes immediate effect — turning on

## Variants

| Variant | Class               | Use for                                  |
|---------|---------------------|------------------------------------------|
| Default | `.switch`           | Standard accent fill when on             |
| Success | `.switch.-success`  | Positive-state toggle (active / safe)    |
| Danger  | `.switch.-danger`   | Risky state (mute / unsafe enable)       |
| Small   | `.switch.-s`        | Compact size for dense UIs               |

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | — |
| `effect` | `glow` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | — |

## Anatomy

```html
<label class="switch">
  <input type="checkbox" class="switch__input" />
  <span class="switch__track"></span>
  <span class="switch__label">Enable notifications</span>
</label>

<!-- Pre-checked -->
<label class="switch -success">
  <input type="checkbox" class="switch__input" checked />
  <span class="switch__track"></span>
  <span class="switch__label">Auto-save</span>
</label>

<!-- Disabled -->
<label class="switch">
  <input type="checkbox" class="switch__input" disabled />
  <span class="switch__track"></span>
  <span class="switch__label">Coming soon</span>
</label>
```

The `<label>` wraps everything so the entire switch is clickable; the
`<input>` is visually hidden but accepts focus and keyboard input.

## Tokens consumed

### From semantic tier
- `--color-border-strong` — track off
- `--color-accent` — track on, focus ring (default)
- `--color-success` — track on (-success)
- `--color-danger` — track on (-danger)
- `--color-surface-canvas` — thumb
- `--color-ink-strong` — label text
- `--shadow-raised` — thumb shadow
- `--radius-pill` — track radius
- `--space-inset-element-s` — thumb inset from track edge
- `--space-gap-elements-m` — gap between track and label
- `--size-control-s` / `-m` — track size base
- `--border-width-hairline` / `--border-width-focus`
- `--font-body-regular-*` — label typography
- `--motion-fast` / `--motion-easing-default` — thumb slide

### Component-tier (defined inline)
- `--switch-track-w`, `--switch-track-h`, `--switch-thumb` —
  overridable for custom sizing

## Accessibility contract

The native `<input type="checkbox">` carries all required semantics:

- Keyboard: Space toggles. Tab/Shift+Tab navigates.
- Screen readers announce "checkbox, checked/not checked" with the
  label.
- The `<label>` wrapping provides the association — no explicit
  `for` attribute needed.
- Focus appears on the track (via `:focus-visible` on the input + CSS
  adjacent selector) because the input itself is visually hidden.

The component uses the standard "visually hidden" pattern for the
input. This is the recommended approach over `appearance: none` +
custom styling because it preserves every accessibility behavior of
the native control.

## Guidance

### Do

- Wrap the input in `<label class="switch">`.
- Use Switch for instant settings; use Checkbox for forms with submit.
- Pair with a concise label that reads as a setting name, not a
  question ("Notifications", not "Do you want notifications?").

### Don't

- Don't use `display: none` on the input. It removes accessibility.
  Use the visually-hidden pattern this component implements.
- Don't put two switches on one row without clear separation. They
  are easily mistaken for siblings of one toggle.
- Don't bind a `click` handler to the track. The native `<input>`
  fires `change` — listen there.