Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Switch

A checkbox with `role="switch"`, drawn as a toggle.

Installation

pnpm dlx zazz-ui add switch
npx zazz-ui add switch
yarn dlx zazz-ui add switch
bunx zazz-ui add switch

Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Notifications
<fieldset data-ui="field-group">
  <legend>Notifications</legend>
  <div
    style="
      --display: flex-col;
      --gap: 2;
    "
  >
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" role="switch" name="notifications-email" />
      <span data-field-slot="label">Email notifications</span>
    </label>
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" role="switch" name="notifications-2fa" checked />
      <span data-field-slot="label">Two-factor authentication</span>
    </label>
  </div>
</fieldset>
/**
 * switch.css — Switch (input[type="checkbox"][role="switch"])
 *
 * @layer      variables, reset
 * @requires   _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow)
 * @uses       appearance: none on input[role="switch"]
 * @uses       label:has(> input[role="switch"])::after — thumb drawn on label (no JS)
 * @uses       oklch(from …) — checked/hover track tints
 * @tokens     --ui-switch-* (@layer variables; consumed in @layer reset)
 */

@layer variables {
  :root {
    --ui-switch-track-w: calc(var(--spacing) * 7);
    --ui-switch-track-padding: calc(var(--spacing) * 0.5);
    --ui-switch-track-h: calc(var(--ui-switch-track-w) / 2 + var(--ui-switch-track-padding));
    --ui-switch-thumb-bg: var(--color-background);
    --ui-switch-thumb-bg--hover: var(--color-primary-foreground);
    --ui-switch-thumb-size: calc((var(--ui-switch-track-w) / 2) - var(--ui-switch-track-padding));
    --ui-switch-thumb-shadow: var(--shadow-xs);
    --ui-switch-thumb-translate: var(--ui-switch-track-padding), -50%;
    --ui-switch-thumb-scale: 1;
    --ui-switch-track-bg: var(--color-muted);
    --ui-switch-track-bg--hover: oklch(from var(--color-muted) l c h / calc(alpha + 0.04));
    --ui-switch-track-bg--checked: var(--color-primary);
    --ui-switch-track-bg--checked-hover: var(--color-primary);
    --ui-switch-rounded: var(--radius-full);
  }
}

@layer reset {
  /* ===========================================================================
    SWITCH — native <input type="checkbox" role="switch"> internals
    In the reset layer so component overrides win.
    =========================================================================== */

  :where(label:has(> input[role="switch"])) {
    position: relative;

    &::after {
      content: "";
      position: absolute;
      inset-block-start: 50%;
      inset-inline-start: 0;
      inline-size: var(--ui-switch-thumb-size);
      block-size: var(--ui-switch-thumb-size);
      border-radius: var(--radius-full);
      background-color: var(--ui-switch-thumb-bg);
      box-shadow: var(--ui-switch-thumb-shadow);
      pointer-events: none;
      transform: translate(var(--ui-switch-thumb-translate)) scale(var(--ui-switch-thumb-scale));
      transition:
        box-shadow var(--default-transition-duration) var(--default-transition-timing-function),
        transform var(--default-transition-duration) var(--default-transition-timing-function);
      z-index: 1;
    }

    &:has(> input[role="switch"]:checked)::after {
      background-color: var(--ui-switch-thumb-bg--hover);
      --ui-switch-thumb-translate:
        calc(var(--ui-switch-track-padding) + var(--ui-switch-thumb-size)), -50%;
    }

    @media (hover: hover) {
      &:has(> input[role="switch"]:hover):after {
        --ui-switch-thumb-scale: 1.1;
      }
    }

    &:has(> input[role="switch"]:active):after {
      --ui-switch-thumb-shadow: none;
      --ui-switch-thumb-scale: 0.98;
    }
  }

  :where(label:has(> input[role="switch"]:disabled)) {
    opacity: 0.5;
    cursor: not-allowed;
  }

  :where(label:has(> input[role="switch"]:disabled) > input[role="switch"]) {
    opacity: 1;
  }

  :where(input[role="switch"]) {
    appearance: none;
    flex-shrink: 0;
    --_w-len: var(--_w-resolved, var(--ui-switch-track-w));
    --_w-num: var(--_w-resolved, var(--ui-switch-track-w));
    inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
    --_h-len: var(--_h-resolved, var(--ui-switch-track-h));
    --_h-num: var(--_h-resolved, var(--ui-switch-track-h));
    block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));

    background-color: var(--_bg-resolved, var(--ui-switch-track-bg));

    border-radius: var(--_rounded-resolved, var(--ui-switch-rounded));
    cursor: pointer;

    /* the transparent outline keeps focus visible in forced-colors */
    --_ring-offset-width: 0px;
    --_ring-width: 0px;
    --_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
    --_focus-shadow:
      0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
      0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
        var(--_ring-fill, var(--ring-shadow-color)),
      inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
      inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
        var(--_ring-fill, var(--ring-shadow-color));
    box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
    outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
    outline-offset: calc(
      var(--outline-offset) - var(--_focus-in) *
        (2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
    );

    transition: var(--default-transition);
  }

  @media (hover: hover) {
    :where(input[role="switch"]:hover) {
      background-color: var(--_bg-resolved, var(--ui-switch-track-bg--hover));
    }
  }

  :where(input[role="switch"]:checked) {
    background-color: var(--_bg-resolved, var(--ui-switch-track-bg--checked));
  }

  @media (hover: hover) {
    :where(input[role="switch"]:hover:checked) {
      background-color: var(--_bg-resolved, var(--ui-switch-track-bg--checked-hover));
    }
  }

  :where(input[role="switch"]:focus-visible) {
    --_ring-offset-width: var(--ring-offset-width);
    --_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
    outline-color: transparent;
  }
}

Attributes

This primitive is a composition of other primitives and utilities: it has no presets, slots, or hooks of its own. Copy its example and adapt it.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

switch hooks
HookDefaultState
--ui-switch-roundedvar(--radius-full)—
--ui-switch-thumb-bgvar(--color-background)—
--ui-switch-thumb-bg--hovervar(--color-primary-foreground)hover
--ui-switch-thumb-scale1—
--ui-switch-thumb-shadowvar(--shadow-xs)—
--ui-switch-thumb-sizecalc((var(--ui-switch-track-w) / 2) - var(--ui-switch-track-padding))—
--ui-switch-thumb-translatevar(--ui-switch-track-padding), -50%—
--ui-switch-track-bgvar(--color-muted)—
--ui-switch-track-bg--checkedvar(--color-primary)checked
--ui-switch-track-bg--checked-hovervar(--color-primary)checked-hover
--ui-switch-track-bg--hoveroklch(from var(--color-muted) l c h / calc(alpha + 0.04))hover
--ui-switch-track-hcalc(var(--ui-switch-track-w) / 2 + var(--ui-switch-track-padding))—
--ui-switch-track-paddingcalc(var(--spacing) * 0.5)—
--ui-switch-track-wcalc(var(--spacing) * 7)—