Switch

A track-and-thumb toggle for on / off settings.

Default

Wrap a <input type="checkbox" role="switch"> in .switch with a paired .switch__label. The role="switch" makes assistive tech announce the affordance correctly.

<div class="demo-stack" style="max-width: 24rem;">
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="defaultSwitch" />
    <label class="switch__label" for="defaultSwitch">Notifications</label>
  </div>
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="checkedSwitch" checked />
    <label class="switch__label" for="checkedSwitch">Auto-update</label>
  </div>
</div>

Large

Add .switch--lg for a larger variant. Suited to standalone settings rows where the switch is the row's primary affordance.

<div class="demo-stack" style="max-width: 24rem;">
  <div class="switch switch--lg">
    <input class="switch__input" type="checkbox" role="switch" id="lgSwitch" />
    <label class="switch__label" for="lgSwitch">Notifications</label>
  </div>
  <div class="switch switch--lg">
    <input class="switch__input" type="checkbox" role="switch" id="lgSwitchOn" checked />
    <label class="switch__label" for="lgSwitchOn">Auto-update</label>
  </div>
</div>

Settings row

Push the switch to the row's trailing edge with .switch--reverse + justify-content: space-between. The label sits on the left as row content; the switch pins right as the affordance.

<div class="demo-stack" style="max-width: 24rem;">
  <div class="switch switch--lg switch--reverse justify-content-between">
    <label class="switch__label" for="settingEmail">Email notifications</label>
    <input class="switch__input" type="checkbox" role="switch" id="settingEmail" checked />
  </div>
  <div class="switch switch--lg switch--reverse justify-content-between">
    <label class="switch__label" for="settingPush">Push notifications</label>
    <input class="switch__input" type="checkbox" role="switch" id="settingPush" />
  </div>
</div>

Disabled

Add disabled to dim the input and its label, and block interaction.

<div class="demo-stack" style="max-width: 24rem;">
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="disabledSwitchOff" disabled />
    <label class="switch__label" for="disabledSwitchOff">Disabled off</label>
  </div>
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="disabledSwitchOn" checked disabled />
    <label class="switch__label" for="disabledSwitchOn">Disabled on</label>
  </div>
</div>

Without label

Drop the .switch wrapper to use a bare .switch__input. Always pair with an aria-label for accessibility.

<div class="demo-row">
  <input class="switch__input" type="checkbox" role="switch" aria-label="Bare switch off" />
  <input class="switch__input" type="checkbox" role="switch" aria-label="Bare switch on" checked />
</div>

Browser validation

Pair required with the native :user-invalid pseudo. The browser fires it after the user interacts with the field, and clears it the moment the switch is on. Use for inline validation where the affordance owns its own state.

<div class="demo-stack" style="max-width: 24rem;">
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="reqSwitch" required />
    <label class="switch__label" for="reqSwitch">Enable two-factor (required)</label>
  </div>
</div>

The track looks valid until you interact. Click it once then click away to trigger :user-invalid. Toggle it on and the red clears.

Server validation

Set aria-invalid="true" from your form library. The attribute is sticky. Frameworks like React Hook Form, Formik, vee-validate, and Rails / Django / Laravel form helpers manage this based on their own error state. Stisla just paints the red border while the attribute is present.

<div class="demo-stack" style="max-width: 24rem;">
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="srvSwitch" aria-invalid="true" />
    <label class="switch__label" for="srvSwitch">Two-factor must be enabled</label>
  </div>
  <div class="switch">
    <input class="switch__input" type="checkbox" role="switch" id="srvSwitchOn" aria-invalid="true" checked />
    <label class="switch__label" for="srvSwitchOn">Enabled (server still flagged)</label>
  </div>
</div>

The red border stays in both off and on states. Track fill follows the on/off semantic; border carries the invalid signal. Both compose at once.

Customization

Seven variables retune .switch without touching component CSS. Override on .switch itself, on a parent scope, or on :root. The cascade scopes the change.

Variable Default Use
--switch-track-w calc(1.75rem * var(--st-density)) Track width; --lg reassigns this
--switch-track-h calc(1rem * var(--st-density)) Track height
--switch-thumb calc(0.75rem * var(--st-density)) Thumb diameter
--switch-inset calc(0.125rem * var(--st-density)) Visible gap between thumb edge and track edge
--switch-off-bg color-mix(in oklch, var(--st-foreground) 25%, transparent) Off-state track; mid-gray that adapts per theme so the white thumb stays readable in both modes
--switch-on-bg var(--st-primary) On-state track
--switch-thumb-color white Thumb fill (white literal so the thumb reads on both the off-state mid-gray and the on-state primary track in both themes)

Track and thumb opt out of --st-radius. The pill and circle are semantic. Override --switch-track-h or --switch-thumb for a different shape; the radius follows the shorter dimension.