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> Optional Form helper only, not the switch. One input in the host. 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.

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> for form 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"
name="newsletter"
tabindex="-1"
aria-hidden="true"
/>
</div>
</cinq-switch>

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

  • aria-label="Battle cries" 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).

Style focus with native pseudo-classes:

cinq-switch:focus-visible {
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
init() Binds markup + listeners. Call destroy() first if already bound.
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();

After mutating light DOM, call destroy() then init():

$switch.destroy();
// mutate light DOM...
$switch.init();

activate() and deactivate() accept an optional emit flag (default true). Pass false to update state without dispatching an event. The Programmatic API playground section includes a silent sync button.

The hidden checkbox stays in sync with aria-checked. Submit the form to read values with FormData (see Form submission in the playground).

The Campaign settings (advanced) playground demo combines:

  • form submit with multiple switches
  • a master switch that enables/disables a child via disabled + tabindex
  • preset loading with activate(false) (no events)
  • locking the whole panel with an external disabled attribute

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 reflected checked attribute on the host.

Underdark vision

State: off

Accessible naming

Per the APG, the switch needs an accessible name via visible text, aria-label, or aria-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. Add checked 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" with aria-labelledby.

Campaign preferencesIdentify magic itemsShow THAC0

Form helper input

Optional hidden <input> for form submission (one per host). Keep it hidden from assistive tech. The host is the switch.

Henchman reports

Form submission

Submit the form to read checkbox values synced by the component. The host stays the accessible switch. The hidden input is for the form only.

Daily henchman reports

Submitted:-

Disabled

Pair disabled with aria-disabled="true" and tabindex="-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

Events:none(silent sync skips them)

Cancellable events

Activate and deactivate events fire before the state changes. Call event.preventDefault() to abort. Useful for confirmations or gating access.

Scrying pool access

Last attempt: -

Campaign settings (advanced)

Form submit, a master switch that enables/disables a child, preset loading viaactivate(false), and external disabled on the whole panel.

Table rulesEnable house rulesCritical fumblesMilestone leveling
Toggle house rules, load a preset, or submit the form.