Skip to content

Toggle

A <label> styled like a button, wrapping a native <input type="checkbox"> (binary toggle) or <input type="radio"> (exclusive group). The label paints the chrome; the input keeps form semantics + keyboard handling + native single-open via the name attribute. Pressed state is driven by :has(input:checked) β€” no JavaScript.

Install

import "elements-kit/ui/styles/theme.css";
import "elements-kit/ui/styles/scaling.css";
import "elements-kit/ui/styles/radius.css";
import "elements-kit/ui/styles/space.css";
import "elements-kit/ui/styles/typography.css";
import "elements-kit/ui/styles/cursor.css";
import "elements-kit/ui/styles/unset.css";
// neutral palette:
import "elements-kit/ui/styles/palette/gray.css";
import "elements-kit/ui/styles/neutral/gray.css";
// any accent scales you want:
import "elements-kit/ui/styles/palette/mint.css";
import "elements-kit/ui/styles/accent/mint.css";
// and the toggle itself:
import "elements-kit/ui/toggle/toggle.css";

API

<!-- Binary toggle (checkbox) -->
<label class="x-toggle" data-variant="surface" data-size="2">
<input type="checkbox" class="unset" />
Bold
</label>
<!-- Exclusive group (radio, native name attribute) -->
<label class="x-toggle">
<input type="radio" name="align" class="unset" />
Left
</label>
<label class="x-toggle">
<input type="radio" name="align" class="unset" checked />
Center
</label>
<label class="x-toggle">
<input type="radio" name="align" class="unset" />
Right
</label>

The unset class on the input strips native chrome (Styles β†’ Unset native styles). Visually only the label shows.

AttributeValues
data-variantsurface (default), soft
data-size1, 2 (default), 3
data-iconmakes the toggle a square (icon-only) β€” drops horizontal padding, locks aspect-ratio: 1
data-accent (on element or any ancestor)any imported color scale
disabled (on the wrapped input)greys out + stops clicks

Sizing

data-sizeheightpadding-xfont-size
1--space-5--space-2--font-size-1
2 (default)--space-6--space-3--font-size-2
3--space-7--space-4--font-size-3

Variants

The rest state uses neutral tokens; the pressed state uses accent (--accent-*) tokens that fall back to neutral via accent/neutral.css when no data-accent is set. Both variants stack three hue-independent signals β€” alpha step, ring/weight, and font-weight: 500 β†’ 600 β€” so pressed stays clearly distinct from rest even in the all-gray case.

  • surface (default) β€” thin --neutral-a5 ring + muted --neutral-a11 text at rest. Pressed: soft --accent-a5 fill + stronger --accent-a8 ring + --accent-12 text. Hover bumps the pressed bg one alpha step deeper (--accent-a6).
  • soft β€” --neutral-a3 tinted track at rest. Pressed: --accent-a5 accent-tinted track + --accent-12 text.

Set data-accent="iris" on the toggle or any ancestor to tint the pressed state. Without an accent, the pressed state stays gray (just deeper than rest) β€” still clearly active.

Grouping

For an exclusive single-select group, give all the radio inputs the same name:

<div style="display: inline-flex; gap: 4px">
<label class="x-toggle"><input type="radio" name="align" class="unset" /> Left</label>
<label class="x-toggle"><input type="radio" name="align" class="unset" /> Center</label>
<label class="x-toggle"><input type="radio" name="align" class="unset" /> Right</label>
</div>

The kit deliberately doesn’t ship a .x-toggle-group class β€” flex + gap is one line.

For a joined-pill segmented control (no gaps between items, one continuous bar), use x-segmented-control instead.

States

  • :has(input:checked) β€” pressed. Drives the bg + color flip.
  • :hover β€” subtle wash on the bg.
  • :focus-within β€” 2px --focus-8 outline at +1px offset (on the label, since the input is visually hidden).
  • :has(input:disabled) β€” 0.6 opacity, pointer-events: none.

Accessibility

Native checkbox / radio semantics are intact β€” the input is visually hidden via off-screen positioning but remains in the tab order and announced by screen readers. Click events bubble through the label to the input, so space / enter toggles state via the browser default behavior. The visible focus outline lives on the label because the input itself has no visual.