A toggle switch is a tiny component that carries a lot of UI weight. It controls settings, it signals state instantly, and it invites touch and keyboard interaction. In this tutorial you will build a polished, accessible, pure CSS toggle switch that uses a real checkbox under the hood. No JavaScript, no dependencies, just semantic HTML and clean CSS.
Why Pure CSS Toggle Switches Matter
Many teams ship toggles that rely on JavaScript for basic interaction that the browser already handles. A native checkbox gives you keyboard support, focus handling, and screen reader semantics for free. Styling that checkbox with a custom label delivers the switch look without extra code. A pure CSS approach reduces surface area for bugs, keeps markup simple, and makes theming straightforward with custom properties. You also get instant compatibility with forms and frameworks because the input still submits a value and responds to change events if you hook them later.
Prerequisites
You will work with a single input and a label, then extend styles with pseudo-elements and variables. The patterns are straightforward, but a few CSS features help a lot.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The switch uses a native checkbox for semantics and a label to render the custom UI. The visual track and knob live inside a span so that the label can also include readable text. This is the complete HTML you will style:
The wrapper provides a hook for layout and sizing. The checkbox is the only interactive element, and the label ties to it via the for attribute. The span with class switch__control will become the pill-shaped track and the circular knob. The second span holds visible text, which keeps the control understandable in every context, from settings pages to modals.
Step 2: The Basic CSS & Styling
Start with color and size variables, a small demo layout, and a reset for the hidden input. The variables let you scale the control and retheme it later without touching selectors. The input stays accessible but visually removed, and the label becomes the click target.
/* CSS */
:root {
--switch-width: 44px;
--switch-height: 24px;
--switch-padding: 2px;
--track-off: #cbd5e1; /* slate-300 */
--track-on: #22c55e; /* green-500 */
--track-disabled: #e5e7eb; /* gray-200 */
--knob: #ffffff;
--knob-shadow: 0 1px 2px rgba(0,0,0,.15), 0 2px 6px rgba(0,0,0,.10);
--text: #0f172a; /* slate-900 */
--muted: #64748b; /* slate-500 */
--focus-color: #3b82f6; /* blue-500 */
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
padding: 2rem;
line-height: 1.5;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.switch__label {
display: inline-flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
user-select: none;
}
.switch__text {
color: var(--muted);
font-size: 0.95rem;
}
/* Visually hide the input but keep it focusable and clickable via the label */
.switch__input {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Sizing variants */
.switch[data-size="sm"] {
--switch-width: 36px;
--switch-height: 20px;
}
.switch[data-size="md"] {
--switch-width: 44px;
--switch-height: 24px;
}
.switch[data-size="lg"] {
--switch-width: 56px;
--switch-height: 32px;
}
Advanced Tip: Keep all track, knob, and spacing values in custom properties. You can theme per component by setting variables on .switch or per page by setting them on :root. It also makes size variants trivial because you override only width and height.
Step 3: Building the Track and Knob
The control span renders both the track and the knob. The track is the span itself, and the knob is its ::after pseudo-element. The knob will slide using a transform later.
/* CSS */
.switch__control {
position: relative;
inline-size: var(--switch-width);
block-size: var(--switch-height);
background: var(--track-off);
border-radius: 9999px; /* pill shape */
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06);
transition: background-color .22s ease-out;
}
/* Knob */
.switch__control::after {
content: "";
position: absolute;
top: var(--switch-padding);
left: var(--switch-padding);
inline-size: calc(var(--switch-height) - var(--switch-padding) * 2);
block-size: calc(var(--switch-height) - var(--switch-padding) * 2);
background: var(--knob);
border-radius: 50%;
box-shadow: var(--knob-shadow);
transform: translateX(0);
transition: transform .22s cubic-bezier(.2,.8,.2,1), box-shadow .22s ease-out;
}
How This Works (Code Breakdown)
The track is a rounded rectangle with a very high border-radius, which creates the familiar pill silhouette. If you want a refresher on shaping pills and racetracks, the concept mirrors how you make an oval with CSS: set width and height, then round the corners fully so the sides become straight and the ends become semicircles.
The knob uses the ::after pseudo-element. It is absolutely positioned inside the track and sized from the track height minus padding on both sides. Setting border-radius to 50% makes a perfect circle. If you need a quick pattern for circular UI elements, revisit how to make a circle with CSS. The knob starts at the left by anchoring top and left to the padding on the track. A small shadow lifts it off the background so the toggle reads as a physical control.
Transitions stay on the properties that will change: background-color for the track and transform for the knob. This keeps the browser on the fast path during interaction.
Step 4: Wiring Interaction, Focus, and Disabled States
Connect the checkbox state to the visual control using adjacent sibling selectors. Add a focus outline for keyboard users and a subtle hover polish for pointer users. Round out the component with a disabled style and support for high-contrast toggles if your theme flips colors.
/* CSS */
/* Checked state: green track and the knob slides to the right */
.switch__input:checked + .switch__label .switch__control {
background: var(--track-on);
}
.switch__input:checked + .switch__label .switch__control::after {
transform: translateX(calc(var(--switch-width) - var(--switch-height)));
}
/* Focus state: show an outline on the track when the input has focus */
.switch__input:focus-visible + .switch__label .switch__control {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Hover polish: brighten the track a touch and sharpen the knob shadow */
.switch__label:hover .switch__control {
filter: brightness(1.03);
}
.switch__label:hover .switch__control::after {
box-shadow: 0 1px 2px rgba(0,0,0,.18), 0 3px 10px rgba(0,0,0,.14);
}
/* Active press: a tiny compression on the knob for tactile feedback */
.switch__label:active .switch__control::after {
transform: translateX(0) scale(0.96);
}
.switch__input:checked + .switch__label:active .switch__control::after {
transform: translateX(calc(var(--switch-width) - var(--switch-height))) scale(0.96);
}
/* Disabled: dim the control and block interaction */
.switch__input:disabled + .switch__label {
cursor: not-allowed;
opacity: .6;
}
.switch__input:disabled + .switch__label .switch__control {
background: var(--track-disabled);
}
/* Motion preferences */
@media (prefers-reduced-motion: reduce) {
.switch__control,
.switch__control::after {
transition: none;
}
}
How This Works (Code Breakdown)
Styling the checked state relies on the input:checked + label relationship. Because the label is the next sibling, any descendant of the label can react to the input state with a single selector chain. The track flips color to a green tone, and the knob translates across by the width minus the height. That travel distance matches the inner length of the rounded track, since the knob diameter equals the track height. This keeps the knob centered at both ends.
The focus ring attaches to the track when the input receives focus via keyboard, using :focus-visible for a cleaner experience. The outline is deliberately outside the track to avoid bloating the pill shape. If you want a primer on the shape that gets outlined here, it is just a rounded rectangle; the basics match how you make a rectangle with CSS, then round it to taste.
Hover styles nudge contrast and shadow without shifting layout. On active press, the knob scales down slightly to suggest depth. The scale is combined with the translate in the checked and unchecked branches so the knob stays in the correct position while compressing.
Disabled controls lose the pointer cursor and fade, which signals to mouse and touch users that the switch is not interactive. The input remains in the tab order only if your framework leaves disabled elements focusable, which the platform does not by default.
Advanced Techniques: Animations & Theming
You can refine the switch with micro-interactions and themes using only variables and a small keyframe. The trick is to avoid animating expensive properties while keeping feedback crisp. The code below introduces a subtle pop when the state changes and a quick way to theme the component for dark mode.
/* CSS */
/* Subtle pop on state change */
.switch__input:checked + .switch__label .switch__control::after,
.switch__input:not(:checked) + .switch__label .switch__control::after {
transition: transform .22s cubic-bezier(.2,.8,.2,1), box-shadow .22s ease-out;
}
/* Optional: micro pop using a compound shadow tweak */
.switch__input:checked + .switch__label .switch__control::after {
box-shadow: 0 1px 2px rgba(0,0,0,.12), 0 6px 16px rgba(0,0,0,.18);
}
/* Dark theme example: flip colors via variables on a parent */
[data-theme="dark"] {
--track-off: #334155; /* slate-700 */
--track-on: #16a34a; /* green-600 */
--knob: #0b1220; /* near-black knob */
--text: #e2e8f0; /* slate-200 */
--muted: #94a3b8; /* slate-400 */
--focus-color: #60a5fa;/* blue-400 */
}
/* Extra size option, no selector changes needed */
.switch.is-xl {
--switch-width: 64px;
--switch-height: 36px;
}
The pop effect avoids keyframes and sticks to transform and shadow, which keeps animation smooth across devices. The theme block demonstrates how variables make restyling safe and local. Place data-theme on a parent container, and every nested switch adopts the palette. The extra size class shows how to scale the control by overriding only width and height, because the knob and padding derive from those values.
Accessibility & Performance
Good UI is not just pixels and motion. The switch must work with keyboard users, assistive tech, and various user preferences. It should also run smoothly under load on mid-range hardware.
Accessibility
The checkbox delivers built-in semantics that screen readers understand. The label provides a name that is announced in every mode. Keep the visible text in the label when the UI allows it. If space is tight, you can move the text off-screen with a visually hidden helper and keep the name intact. The control span has aria-hidden=”true” because it is purely decorative, and the input stays focusable, which also enables Space to toggle it by default.
Use :focus-visible to show a clear outline when users navigate with a keyboard. The outlined pill is easy to see against a variety of backgrounds. Respect motion preferences by turning off transitions under the prefers-reduced-motion media query, which removes animations for users who ask for calmer movement.
Some teams add role=”switch” and aria-checked to produce switch semantics instead of checkbox semantics. That pattern requires updating aria-checked whenever the state changes. Without JavaScript you cannot keep that attribute synchronized, so stick with the native checkbox role in a pure CSS setup.
Performance
This switch animates only transform and background-color. Those properties are cheap for browsers to animate because they avoid layout and paint thrash. The knob shadow is not animated; it only changes at discrete moments, which avoids a heavy blur on every frame. You can skip will-change for a component this small, since the knob already transitions smoothly without reserving GPU memory. If you scale this pattern to a large list of toggles, keep shadows simple and lower their spread to prevent extra raster work during page scroll.
Ship Polished Toggles With Zero JavaScript
You built a pure CSS toggle switch powered by a semantic checkbox, a styled label, and a compact set of selectors. You wired checked, focus, hover, active, disabled, and motion preferences, and you learned how to theme and resize the component with variables. Now you have a drop-in pattern you can adapt to any design system and the know-how to extend it into a full suite of form controls.