defuss-shadcn / Forms & Inputs / switch
SwitchATM
A toggle control for on/off states. Built on native <input type="checkbox" role="switch"> with CSS-only thumb animation.
On this page (8)
§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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
checked | boolean | true, false | false | Native checked state (role="switch" ⇄ panel checkbox). |
disabled | boolean | true, false | false | Interaction off (native disabled attribute). |
size | enum | xs, 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