Theme
On this page (8)
Component Skill — components/switch/component-skill.md

Native basis

<input type="checkbox" role="switch"> element styled as a toggle switch.

Web Platform APIs

<input type="checkbox">role="switch":checked:focus-visible:has()prefers-reduced-motionforced-colors

Classes

.switch.switch-item.switch-item-block.switch-description

Sizes (data-size)

xsTrack: 1.5rem × 0.875remsmTrack: 1.75rem × 1remmdTrack: 2.25rem × 1.25rem (default)lgTrack: 2.75rem × 1.5remxlTrack: 3.25rem × 1.75rem

Accessibility

• role="switch" tells screen readers this is an on/off toggle, not a checkbox.

• aria-checked is automatically set by the browser for type="checkbox".

• :focus-visible provides keyboard-only focus ring.

• prefers-reduced-motion disables all transitions.

• forced-colors provides Windows High Contrast Mode support.

Notes

• Pure CSS - no JavaScript needed.

• The thumb slides via :checked and translateX().

• Uses appearance: none with ::after for the thumb.

• Wrap in .switch-item for auto disabled label styling via :has().

• aria-invalid="true" shows the track in the destructive color.

§Default

Toggle switch with label.

§With Description

Switch with label and description text using .switch-item-block.

§Sizes

The full five-step scale via data-size - the default equals md.

§Checked by default

§Disabled

Disabled stays legible: the track keeps its full shade (a softened primary when on), the knob turns muted, and .switch-item mutes the label and shows the not-allowed cursor over it via :has() - no opacity fade.

§Invalid

Validation error state with aria-invalid='true'.

§States

Machine contract - verified against switch.schema.json by bun run verify:

StateTypeValuesDefaultDescription
checkedbooleantrue, falsefalseNative checked state (role="switch" ⇄ panel checkbox).
disabledbooleantrue, falsefalseInteraction off (native disabled attribute).
sizeenumxs, sm, md, lg, xl"md"Control scale (display sizes).

§CSS view file

/* -- Switch component ------------------------------------------- */
@layer components {
  .switch {
    appearance: none;
    margin: 0; /* kill the UA's ~4px input margin - breaks flex/grid rhythm */
    width: 2.25rem;
    height: 1.25rem;
    border-radius: 9999px;
    background: var(--input);
    cursor: pointer;
    position: relative;
    flex-shrink: 0;
    transition: background-color 150ms;
    border: none;
    outline: none;
    /* Thumb */
    &::after {
      content: '';
      position: absolute;
      top: 2px;
      left: 2px;
      width: calc(1.25rem - 4px);
      height: calc(1.25rem - 4px);
      border-radius: 50%;
      background: var(--background);
      transition: transform 150ms;
      box-shadow: 0 1px 2px oklch(0 0 0 / 0.15);
    }
    &:checked {
      background: var(--primary);
      &::after {
        transform: translateX(1rem);
      }
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    /* Disabled = legible but inert: never an opacity fade (0.5 on a light
       --border edge left a ghost you had to look for). The shape keeps its
       full-strength edge, the surface turns muted, a chosen state keeps a
       softened primary fill, and the label goes muted with the no-entry
       cursor - "a choice that exists but is unavailable right now". */
    &:disabled {
      cursor: not-allowed;
      /* the off track keeps its full --input shade; an edge ring holds the
         outline even where --input sits close to the page */
      box-shadow: inset 0 0 0 1px var(--border);
      &::after {
        background: var(--muted);
        box-shadow: 0 0 0 1px var(--border);
      }
      &:checked {
        background: color-mix(in oklch, var(--primary) 45%, var(--background));
        box-shadow: none;
        &::after { background: var(--background); box-shadow: none; }
      }
    }
    &[aria-invalid="true"] {
      background: var(--destructive);
      &:checked {
        background: var(--destructive);
      }
    }
    /* -- Sizes ----------------------------------------------- */
    &[data-size="xs"] {
      width: 1.5rem;
      height: 0.875rem;
      &::after {
        width: calc(0.875rem - 4px);
        height: calc(0.875rem - 4px);
      }
      &:checked::after {
        transform: translateX(0.625rem);
      }
    }
    &[data-size="sm"] {
      width: 1.75rem;
      height: 1rem;
      &::after {
        width: calc(1rem - 4px);
        height: calc(1rem - 4px);
      }
      &:checked::after {
        transform: translateX(0.75rem);
      }
    }
    &[data-size="md"] {
      /* same as the default - explicit member of the scale */
    }
    &[data-size="lg"] {
      width: 2.75rem;
      height: 1.5rem;
      &::after {
        width: calc(1.5rem - 4px);
        height: calc(1.5rem - 4px);
      }
      &:checked::after {
        transform: translateX(1.25rem);
      }
    }
    &[data-size="xl"] {
      width: 3.25rem;
      height: 1.75rem;
      &::after {
        width: calc(1.75rem - 4px);
        height: calc(1.75rem - 4px);
      }
      &:checked::after {
        transform: translateX(1.5rem);
      }
    }
  }
  /* -- Switch item layout ----------------------------------- */
  .switch-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    & > label {
      font-size: 0.875rem;
      cursor: pointer;
    }
  }
  /* With-description layout: label + description stack beside the switch.
     The switch spans both rows, so grid auto-placement (definite-row items
     first) pins it to column 1 - the track template must therefore lead with
     the auto (switch) track. `1fr auto` here once placed the text in the far
     right track (reported: "description renders on the far right"). */
  .switch-item-block {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0 0.75rem;
    align-items: center;
    & > .switch {
      grid-row: 1 / 3;
    }
    & > label {
      font-size: 0.875rem;
      font-weight: 500;
      cursor: pointer;
    }
    & > .switch-description {
      font-size: 0.8125rem;
      color: var(--muted-foreground);
      line-height: 1.5;
    }
  }
  /* Gap hit area: inside an item layout the control's click target spans
     the gap to its label (a transparent ::before, part of the input's own
     hit box), so clicking the whitespace between control and text toggles
     too - the label's `for` covers the text itself. Inline-end only: the
     label always sits after the control (block layouts place it in the
     second grid column). */
  :is(.switch-item) > .switch::before {
    content: '';
    position: absolute;
    inset-block: -0.25rem;
    inset-inline: -0.25rem calc(-1 * 0.5rem);
  }
  :is(.switch-item-block) > .switch::before {
    content: '';
    position: absolute;
    inset-block: -0.25rem;
    inset-inline: -0.25rem calc(-1 * 0.75rem);
  }
  /* Label disabled styling via :has() */
  .switch-item:has(.switch:disabled),
  .switch-item-block:has(.switch:disabled) {
    cursor: not-allowed;
    /* the words say "unavailable" too - no hand pointer over a label whose
       click cannot flip the switch */
    & > label {
      color: var(--muted-foreground);
      cursor: not-allowed;
    }
  }
  /* -- Accessibility ---------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .switch,
    .switch::after {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .switch {
      background: ButtonFace;
      border: 1px solid ButtonText;
      &::after {
        background: ButtonText;
        box-shadow: none;
      }
      &:checked {
        background: Highlight;
        border-color: Highlight;
        &::after {
          background: HighlightText;
        }
      }
      &:disabled {
        border-color: GrayText;
        &::after {
          background: GrayText;
        }
      }
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub