Skip to content

Switch

A switch is an on/off control that represents a binary setting. <cinq-switch> provides an accessible, keyboard-navigable interface following the WAI-ARIA Authoring Practices switch pattern, reads its state from the markup, syncs an optional hidden checkbox, and dispatches events when toggled.

Inspired by @19h47/switch.

Terminal window
pnpm add @agencecinq/switch

Import once:

import "@agencecinq/switch";

Then write the WAI-ARIA markup. The host <cinq-switch> is the focusable switch element — set role="switch", aria-checked, and tabindex explicitly.

HTML is the source of truth. The component will not auto-set role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.

<cinq-switch role="switch" aria-checked="false" tabindex="0">
<span class="label">Infravision</span>
<span class="switch" aria-hidden="true"><span></span></span>
<span class="on" aria-hidden="true">On</span>
<span class="off" aria-hidden="true">Off</span>
<div hidden>
<input type="checkbox" name="infravision" tabindex="-1" aria-hidden="true" />
</div>
</cinq-switch>

When disabled, use disabled or aria-disabled="true" and tabindex="-1":

<cinq-switch role="switch" aria-checked="false" disabled aria-disabled="true" tabindex="-1">
<span class="label">Cursed item</span>
<span class="switch" aria-hidden="true"><span></span></span>
<div hidden>
<input type="checkbox" tabindex="-1" aria-hidden="true" disabled />
</div>
</cinq-switch>

The component upgrades automatically on connectedCallback — no manual initialization is needed.

Attribute / element Required Role
<cinq-switch> Yes Host element with role="switch".
role="switch" Yes Identifies the control as a switch.
aria-checked Yes Current on/off state ("true" or "false").
tabindex="0" Yes Makes the switch keyboard-focusable (use -1 when disabled).
Accessible name Yes Via aria-label, aria-labelledby, or visible label text.
Hidden input[type="checkbox"] Optional Form helper only — not the switch. Keep it in a hidden container with tabindex="-1" and aria-hidden="true".
aria-hidden="true" on On/Off text Recommended Decorative state labels must not appear in the accessible name.

Use [data-switch-input] instead of input[type="checkbox"] when you need a more specific selector.

Per the APG switch pattern, the switch label must not change when its state changes. Style On/Off decorative text with CSS and mark it aria-hidden="true".

For multiple switches, use a <fieldset> / <legend> or role="group" with aria-labelledby.

The playground below covers every supported markup pattern:

Variant When to use
Visible label text Default — text inside <cinq-switch> becomes the accessible name.
aria-label Icon-only or compact switches with no visible label.
aria-labelledby Name lives in a separate visible element (heading, table cell, etc.).
aria-describedby Extra static help text announced with the switch.
Minimal No slider, no checkbox — UI-only toggle outside a form.
Pre-checked aria-checked="true" (+ optional checked for initial CSS).
Form helper Hidden input[type="checkbox"] or [data-switch-input] for submission.
Switch group <fieldset> / <legend> wrapping several switches.
Disabled disabled + aria-disabled + tabindex="-1", or aria-disabled alone.
<cinq-switch
role="switch"
aria-checked="false"
tabindex="0"
aria-label="Battle cries"
>
<span class="slider" aria-hidden="true"></span>
</cinq-switch>
<span id="prefs-reduced-motion">Show THAC0</span>
<cinq-switch
role="switch"
aria-checked="false"
tabindex="0"
aria-labelledby="prefs-reduced-motion"
>
<span class="slider" aria-hidden="true"></span>
</cinq-switch>
<cinq-switch
role="switch"
aria-checked="false"
tabindex="0"
aria-describedby="push-desc"
>
<span class="label">Torch light</span>
<span class="slider" aria-hidden="true"></span>
</cinq-switch>
<p id="push-desc">Keeps a dim light source active while the party explores underground.</p>
<cinq-switch
class="autoplay-switch"
role="switch"
aria-checked="false"
tabindex="0"
>
Auto-map corridors
</cinq-switch>
.autoplay-switch {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
color: #6b7280;
}
.autoplay-switch[aria-checked="true"],
.autoplay-switch[checked] {
border-color: #111827;
background-color: #111827;
color: #fff;
font-weight: 600;
}
<cinq-switch role="switch" aria-checked="true" checked tabindex="0">
<span class="label">Sage's gazette</span>
<span class="slider" aria-hidden="true"></span>
</cinq-switch>

Set checked on the host in markup when you style with cinq-switch[checked] before the first toggle. The component reflects checked automatically after state changes.

<fieldset>
<legend>Campaign preferences</legend>
<cinq-switch role="switch" aria-checked="false" tabindex="0">
<span class="label">Identify magic items</span>
<span class="slider" aria-hidden="true"></span>
</cinq-switch>
<cinq-switch role="switch" aria-checked="true" checked tabindex="0">
<span class="label">Show THAC0</span>
<span class="slider" aria-hidden="true"></span>
</cinq-switch>
</fieldset>
<cinq-switch role="switch" aria-checked="false" tabindex="0">
<span class="label">Henchman reports</span>
<span class="slider" aria-hidden="true"></span>
<div hidden>
<input
type="checkbox"
data-switch-input
name="newsletter"
tabindex="-1"
aria-hidden="true"
/>
</div>
</cinq-switch>

The switch must have an accessible name. Use one of:

  • aria-label="…" on <cinq-switch>
  • aria-labelledby="some-id" referencing a visible label
  • Visible label text inside the slotted content (when it provides the name)

The component does not check this — rely on axe-core / Lighthouse / your CI.

Key Function
Tab Moves keyboard focus to the switch (tabindex="0" in markup).
Space Toggles between on and off when focused.
Enter Toggles between on and off when focused (optional per APG).

On focus / blur, a .focus class is toggled on the host so you can style the focused state without relying solely on :focus:

cinq-switch.focus {
outline: 2px solid currentColor;
outline-offset: 2px;
}
Attribute Required Description
checked No Reflected state on the host — useful for styling (cinq-switch[checked]).
disabled No Observed — syncs the hidden checkbox and blurs focus when set externally. Pair with aria-disabled="true" and tabindex="-1" in markup.
Method Description
activate(emit?) Turns the switch on. Dispatches an event by default.
deactivate(emit?) Turns the switch off. Dispatches an event by default.
toggle() Toggles on/off. Returns false if disabled or a cancelable event was aborted.
destroy() Detaches listeners when removing the element from the DOM.
Property Description
checked Whether the switch is currently on.
disabled Whether the switch is disabled.
$input The optional hidden checkbox element.
Event Cancelable Detail Description
switch:activate Yes { el } Fired before the switch turns on. Cancel to abort.
switch:deactivate Yes { el } Fired before the switch turns off. Cancel to abort.
import { EVENTS } from "@agencecinq/utils";
$switch.addEventListener(EVENTS.SWITCH_ACTIVATE, (event) => {
console.log("activated", event.detail.el);
});
$switch.addEventListener(EVENTS.SWITCH_DEACTIVATE, (event) => {
console.log("deactivated", event.detail.el);
});

Call event.preventDefault() to abort before the state changes:

$switch.addEventListener(EVENTS.SWITCH_ACTIVATE, (event) => {
if (!userMayEnable()) {
event.preventDefault();
}
});
const $switch = document.querySelector("cinq-switch");
$switch.activate();
$switch.deactivate();
$switch.toggle();
$switch.destroy();

activate() and deactivate() accept an optional emit flag (default true). Pass false to update state without dispatching an event.

Basic switch

Click or press Space / Enter to toggle. Activate and deactivate events are logged below.

Infravision

Last event:

On / Off labels

A larger track can reveal On and Off text inside the slider. Style with [aria-checked="true"] or the reflectedchecked attribute on the host.

Underdark vision

State: off

Accessible naming

Per the APG, the switch needs an accessible name via visible text, aria-label, oraria-labelledby.

aria-label

No visible label — name comes from aria-label.

aria-labelledby

The visible heading above is referenced by aria-labelledby.

Minimal markup

No slider, no hidden checkbox — enough when you do not submit a form. The visible text inside the host provides the accessible name. Style the on state with[aria-checked="true"] or [checked].

Auto-map corridors

Pre-checked state

Set aria-checked="true" in markup for an initially on switch. Addchecked on the host too if you style with cinq-switch[checked]— the component reflects checked after the first toggle.

Sage's gazette

Description

Use aria-describedby when additional static help text should be announced with the switch.

Torch light

Keeps a dim light source active while the party explores underground.

Switch group

Wrap related switches in a <fieldset> with a<legend>, or use role="group" witharia-labelledby.

Campaign preferencesIdentify magic itemsShow THAC0

Form helper input

Use [data-switch-input] when several checkboxes live inside the host. Keep it hidden from assistive tech — the host is the switch.

Henchman reports

Disabled

Pair disabled with aria-disabled="true" andtabindex="-1". The getter also respects aria-disabled alone.

disabled + aria-disabledExperimental kits
aria-disabled only1st-edition THAC0

Programmatic API

Call activate(), deactivate(), or toggle() from external controls.

Auto-save session notes

State: off

Cancellable events

Activate and deactivate events fire before the state changes. Callevent.preventDefault() to abort — useful for confirmations or gating access.

Scrying pool access

Last attempt: