Toggle
A switch for a setting with two states that takes effect the moment it's flipped.
When to Use
toggle-* and launchpad-toggle-* markup with one accessible checkbox-based switch.
Variants
Bare switch
Without a label the switch has no accessible name, so only use this form when a visible label sits beside it in your own markup — a settings row where the left column names the setting.
<?= ws_toggle('demo_off') ?>
<?= ws_toggle('demo_on', ['checked' => true]) ?>With a label
The preferred form. The label becomes the switch's accessible name and its click target, so the hit area covers the text as well as the switch.
<?= ws_toggle('digest', ['label' => 'Subscribe to digest', 'checked' => true]) ?>With a description
Add a description when the consequence isn't obvious from the label. It's wired as aria-describedby, so it's announced after the label rather than merged into it.
<?= ws_toggle('marketing_emails', [
'checked' => true,
'label' => 'Subscribe to digest',
'description' => 'Weekly facilitation tips in your inbox',
]) ?>States
| State | Behavior |
|---|---|
| Off | Default. Track is --gray-200, knob sits left. |
| On | Track fills with the brand colour and the knob slides right over --transition-base. Driven by the native :checked state, not a class. |
| Hover | No distinct hover treatment on the track — the label's pointer cursor is the affordance. |
| Focus | The real <input type="checkbox"> is visually hidden but still focusable, so the browser's focus ring lands on the switch. Don't replace the input with a styled <div>; this is what makes it keyboard-operable. |
| Disabled | Sets the native disabled attribute and adds .ws-toggle-container--disabled, which dims the whole row. Works in both on and off positions, as above. |
<?= ws_toggle('locked_off', ['disabled' => true, 'label' => 'Locked off']) ?>Real-World Usage
A notification settings panel: each toggle names the setting, describes the consequence, and persists on change rather than waiting for a save button — which is the contract that justifies a switch over a checkbox.
<?= ws_toggle('notify_digest', [
'checked' => $prefs['digest'],
'label' => 'Weekly digest',
'description' => 'A Saturday roundup of new exercises and articles',
'attrs' => ['data-pref' => 'digest'],
]) ?>
<script>
// Persist on change — a switch must not need a save button.
document.querySelectorAll('[data-pref]').forEach(el =>
el.addEventListener('change', () => savePref(el.dataset.pref, el.checked)));
</script>Options
| Option | Type | Default | Purpose |
|---|---|---|---|
$name | string | '' | Positional. The input's name — effectively required, since it identifies the setting on submit. |
checked | bool | false | Initial state. Drive it from stored preference, never hardcode. |
label | string | null | Trailing label. Also becomes the accessible name via aria-labelledby. |
description | string | null | Sub-text under the label, wired as aria-describedby |
disabled | bool | false | Native disabled state plus the dimmed container class |
id | string | 'ws-toggle-' . uniqid() | Input ID. Auto-generated so labels always bind correctly — only set it if you need a stable JS handle. |
class | string | '' | Additional CSS classes on the wrapper |
attrs | array | [] | Extra HTML attributes on the checkbox input — where data-* hooks and event bindings go |
Accessibility
This is among the best-wired components in the library — it builds on a native checkbox rather than simulating one.
| Concern | Behavior |
|---|---|
| Semantics | A real <input type="checkbox">, visually hidden and styled through its adjacent slider. Checked state, focusability, and form submission all come free. |
| Keyboard | Native: Tab to reach, Space to flip. Disabled toggles are skipped. |
| Labelling | The label element gets an ID and is referenced by aria-labelledby; the description gets its own ID via aria-describedby. Both are announced, in that order. |
| Unlabelled toggles | Your responsibility. Calling ws_toggle() without a label produces a switch with no accessible name. Either pass a label or point attrs => ['aria-label' => …] at the visible text beside it. |
| Announced as a checkbox | Because the control is a checkbox, screen readers say "checkbox, checked" rather than "switch, on". Functionally equivalent and reliably supported — adding role="switch" would change the wording but risks losing native behaviour, so it isn't done. |
| Immediate effect | Flipping a switch changes something right away with no confirmation step. If the change is destructive or slow, either confirm it or surface the result — silence reads as failure. |
Tokens
| Token | Used for |
|---|---|
--gray-200 | Track in the off position |
--color-primary / --phase-learn / --planner-primary | Track fill when on, per context |
--color-ink / --color-ink-muted / --color-ink-faint | Label, description, and disabled text |
--radius-full | Track and knob shape |
--shadow-sm | Knob elevation |
--space-3 | Gap between the switch and the label block |
--text-ui / --text-caption | Label and description type scale |
--font-body / --font-medium | Face and label weight |
--transition-base | Knob slide and track colour change |
CSS Classes
| Class | Purpose |
|---|---|
.ws-toggle-container | Row wrapper holding the switch and the label block |
.ws-toggle-container--disabled | Dimmed disabled row |
.ws-toggle | The switch itself — the label element wrapping input and slider |
.ws-toggle__input | The visually hidden checkbox. Focusable; do not display: none it. |
.ws-toggle__slider | Track and knob, driven by the input's :checked state |
.ws-toggle__label-wrap | Label plus description column |
.ws-toggle__label | Label text |
.ws-toggle__description | Sub-text |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_toggle() helper function |
includes/components/toggle.php | Template — ID generation and ARIA wiring |
includes/components/components.css | Styles (.ws-toggle rules) |