Neumorphism Switch Toggle: Soft UI On/Off With CSS Only
Build a pixel-perfect neumorphic toggle switch using pure CSS — no JavaScript, no libraries, just soft shadows, smooth transitions, and accessible markup.
Why a Toggle Switch Is the Perfect Neumorphism Demo
Toggle switches are small. They're self-contained. And they have exactly two states — pressed-in and popped-out — which maps perfectly to what neumorphism does with shadows. That's why, whenever you're learning soft UI, the switch toggle is the canonical starting point. It's the "Hello, World" of neumorphism.
The core trick is dual-shadow depth. Neumorphism creates the illusion that an element is extruded from or pressed into the same surface it sits on. You do that with two box-shadow values — one lighter than the background going top-left, one darker going bottom-right. Flip those when the element is active and you push it inward. That's the entire mechanic. Everything else is just polish.
In practice, a toggle is more instructive than a button because you also have to animate the thumb sliding across the track and switch the shadow direction on the container simultaneously. It forces you to think in states, not just static styles. And it does it all without a single line of JavaScript if you use the checkbox hack correctly.
Worth noting: neumorphism hit mainstream awareness around 2019–2020 after Michal Malewicz's Dribbble post went viral. By 2026 it's settled into a mature niche — not a trend chaser's toy, but a deliberate aesthetic choice for productivity apps, settings pages, and audio/hardware-adjacent UIs. The toggle switch is where it genuinely shines.
The Shadow Math You Need to Know
Every neumorphic element needs three numbers: the base background color, the light shadow color (~15% lighter), and the dark shadow color (~15% darker). If your base is #e0e5ec, your light offset is around #ffffff and your dark is #a3b1c6. Run those through HSL and adjust saturation to match your palette. Getting this wrong is why most neumorphism attempts look muddy or plasticky.
The shadow syntax for an extruded (raised) element looks like this:
``css
.track {
background: #e0e5ec;
box-shadow:
6px 6px 12px #a3b1c6,
-6px -6px 12px #ffffff;
}
`
For a pressed-in (inset) element — the "on" state of your track — you flip it to inset:
`css
.track.on {
box-shadow:
inset 6px 6px 12px #a3b1c6,
inset -6px -6px 12px #ffffff;
}
`
That single inset` keyword is doing all the heavy lifting. The shadow offsets stay the same, the colors stay the same — only the direction flips.
The blur radius matters more than the offset. At 4px blur you get a sharp, cheap-looking emboss. At 20px+ it starts looking like a drop shadow from a different element. A 10–14px blur radius is the sweet spot for most toggle sizes. If you're building a 48px-tall track (a good accessible minimum), I'd go with box-shadow: 5px 5px 10px #a3b1c6, -5px -5px 10px #ffffff for raised and the inset equivalent for pressed.
Quick aside: don't mess with the background color of the thumb versus the track. Keep them identical — #e0e5ec on both — and let the shadow do all the differentiation. The moment you add a different fill color on the thumb, you break the extruded-from-surface illusion and it just looks like a regular toggle with weird shadows.
Building the Toggle: HTML Structure
The cleanest approach uses a real <input type="checkbox"> paired with a <label>. No JavaScript, screen-reader compatible out of the box, keyboard-focusable. Here's the markup:
``html
<label class="nm-switch" for="toggle-1">
<input type="checkbox" id="toggle-1" class="nm-switch__input" />
<span class="nm-switch__track">
<span class="nm-switch__thumb"></span>
</span>
<span class="nm-switch__label">Dark Mode</span>
</label>
`
The <input> gets visually hidden (not display:none, which breaks keyboard navigation) and the <label> wraps everything so clicking anywhere on the component toggles state. The .nm-switch__track and .nm-switch__thumb are purely presentational <span>` elements — fine for a decorative toggle.
You'll hide the input while keeping it accessible:
``css
.nm-switch__input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`
Never use visibility: hidden or opacity: 0` here. Both remove the element from the accessibility tree in some screen reader configurations.
Honestly, the wrapper <label> trick is underused. Developers reach for JavaScript click handlers when a for attribute would do the same thing with zero runtime cost. The :checked pseudo-class then lets you drive every visual state change purely in CSS — the track inverting its shadows, the thumb sliding, the accent color appearing. All declarative.
The Full CSS: Track, Thumb, Transitions
Here's the complete CSS for the toggle. I'm using custom properties so you can swap the palette without hunting through the file:
``css
:root {
--nm-bg: #e0e5ec;
--nm-shadow-light: #ffffff;
--nm-shadow-dark: #a3b1c6;
--nm-accent: #6c63ff;
--nm-track-w: 56px;
--nm-track-h: 28px;
--nm-thumb-size: 20px;
}
.nm-switch {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.nm-switch__track {
position: relative;
width: var(--nm-track-w);
height: var(--nm-track-h);
border-radius: 100px;
background: var(--nm-bg);
box-shadow:
5px 5px 10px var(--nm-shadow-dark),
-5px -5px 10px var(--nm-shadow-light);
transition: box-shadow 0.25s ease;
}
/* checked → track presses inward */
.nm-switch__input:checked + .nm-switch__track {
box-shadow:
inset 5px 5px 10px var(--nm-shadow-dark),
inset -5px -5px 10px var(--nm-shadow-light);
}
.nm-switch__thumb {
position: absolute;
top: 50%;
left: 4px;
transform: translateY(-50%);
width: var(--nm-thumb-size);
height: var(--nm-thumb-size);
border-radius: 50%;
background: var(--nm-bg);
box-shadow:
3px 3px 6px var(--nm-shadow-dark),
-3px -3px 6px var(--nm-shadow-light);
transition: left 0.25s ease, box-shadow 0.25s ease;
}
/* checked → thumb slides right and presses in */
.nm-switch__input:checked + .nm-switch__track .nm-switch__thumb {
left: calc(var(--nm-track-w) - var(--nm-thumb-size) - 4px);
box-shadow:
inset 3px 3px 6px var(--nm-shadow-dark),
inset -3px -3px 6px var(--nm-shadow-light);
}
``
That transition: left 0.25s ease on the thumb is all you need for the slide animation. 250ms feels snappy without being jarring. Go below 150ms and it feels like a glitch. Go above 350ms and users start second-guessing whether the click registered.
One more thing — adding a pop of color on the active state makes it far more readable as a UI signal, because the shadow inversion alone is subtle on smaller screens:
``css
.nm-switch__input:checked + .nm-switch__track::after {
content: '';
position: absolute;
inset: 0;
border-radius: 100px;
background: var(--nm-accent);
opacity: 0.12;
transition: opacity 0.25s ease;
}
``
That 12% accent tint on the track gives a purple (or whatever your brand color is) wash when enabled, while keeping the neumorphic shadow effect intact. It bridges the gap between pure soft UI and usable product design.
For focus styles, add this so keyboard users aren't left in the dark:
``css
.nm-switch__input:focus-visible + .nm-switch__track {
outline: 2px solid var(--nm-accent);
outline-offset: 3px;
}
`
:focus-visible` means mouse users won't see the outline ring, but Tab-key navigation will. That's the right behavior in 2026 — don't strip focus styles entirely and don't show them on mouse clicks.
Adding a Dark Mode Variant
Neumorphism on dark backgrounds is tricky. The math changes: your "light" shadow becomes a very subtle brightness boost (not white), and your "dark" shadow becomes near-black. Something like base #1e2127, light shadow #2a2e38, dark shadow #13151a. The contrast between the two shadows is much smaller on dark surfaces — that's normal and intentional.
``css
@media (prefers-color-scheme: dark) {
:root {
--nm-bg: #1e2127;
--nm-shadow-light: #2a2e38;
--nm-shadow-dark: #13151a;
--nm-accent: #8b7eff;
}
}
``
Swapping the custom properties is all you need. The shadow declarations stay identical. That's the payoff for defining everything through CSS variables upfront.
Look, dark-mode neumorphism divides opinions. Some designers think the effect disappears entirely on dark surfaces because human perception is less sensitive to subtle dark-on-dark gradients. They're not wrong — you need to nudge up your shadow spread or increase the lightness difference between --nm-shadow-light and --nm-shadow-dark by 3–5% more than you would on light mode. Test on an actual device, not just your calibrated design monitor.
If you want to see how Empire UI handles light and dark neumorphic surfaces side-by-side, head to the neumorphism style hub. Every component there ships with both mode variants and you can toggle between them live to calibrate your own palette.
Accessibility and Browser Considerations
The checkbox + label technique gives you keyboard and screen reader support for free, but there's one more thing you should add: role="switch" on the <label> and aria-checked synced to the checkbox state. Pure CSS can't update aria-checked dynamically, so this is the one place where a tiny JavaScript sprinkle is worth it:
``js
document.querySelectorAll('.nm-switch').forEach(switchEl => {
const input = switchEl.querySelector('input[type="checkbox"]');
const track = switchEl.querySelector('.nm-switch__track');
track.setAttribute('role', 'switch');
track.setAttribute('aria-checked', String(input.checked));
input.addEventListener('change', () => {
track.setAttribute('aria-checked', String(input.checked));
});
});
``
Fifteen lines of vanilla JS. No framework needed. Screen readers like NVDA and VoiceOver will then announce "Dark Mode, switch, on" or "off" correctly.
Browser support for this entire technique is excellent. box-shadow with inset has been around since CSS 2.1. CSS custom properties work in every browser since 2017. The :focus-visible pseudo-class landed in all major browsers by 2022. There's genuinely nothing here that needs a polyfill in 2026.
Performance-wise, box-shadow transitions are GPU-accelerated in Chromium and Safari (they composite on the layer). Firefox composites them too since version 112. That said, if you're rendering hundreds of these toggles in a virtualized list (say, a settings page with 200 rows), consider consolidating shadow repaints — but for the typical use case of 1–5 toggles per page, you're fine.
One thing that trips people up: don't animate background-color on the track itself for the color tint. Use a ::after pseudo-element with opacity transition instead, as shown above. Animating background-color forces a style recalculation on every frame; animating opacity on a composed layer is significantly cheaper.
Taking It Further: Variations and Where to Use It
The base toggle is a solid foundation, but there are a few variations worth building out. A size scale — small (20px track height), medium (28px), large (36px) — covers every use case from a dense settings panel to a prominent hero-section preference toggle. Just adjust the custom properties per class: .nm-switch--sm, .nm-switch--md, .nm-switch--lg.
An icon variant adds a sun/moon icon inside the thumb using a ::before pseudo-element. Keep the icon at 12px and centered, use an SVG data URL as background-image, and transition background-image on the :checked state. It adds a lot of personality for minimal code cost.
In practice, neumorphic toggles work best in settings pages, preferences panels, audio/visual control UIs, and dashboards where the surface color is consistently that pale gray. They look odd dropped into a colorful marketing page or over a glassmorphism background — for those contexts, reach for a different style. You can compare both approaches by exploring glassmorphism components and the neumorphism hub side by side.
If you're building out a whole soft UI settings page, the box shadow generator is genuinely useful for dialing in shadow values before you commit them to code. Paste in your background color, drag the sliders, copy the CSS. Takes 30 seconds instead of 10 minutes of manual guessing.
And if you want a pre-built, production-ready neumorphic component set — toggles, buttons, cards, inputs, sliders, the whole lot — Empire UI's neumorphism collection has it ready to copy and deploy. No design decisions to make, no shadow math to figure out from scratch. Just grab the component and ship.
FAQ
Yes — the checkbox-plus-label technique drives all visual states via CSS :checked. The only reason to add JS is for aria-checked updates, which screen readers need to announce the on/off state correctly.
A mid-range gray in the #d0d8e4 to #e8ecf1 range. Too dark and the shadow contrast disappears; too light and there's no room for a lighter highlight shadow above it.
It does, but the shadow contrast needs to increase slightly because human vision is less sensitive to subtle dark variations. Increase the lightness difference between your two shadow colors by about 4–6% compared to your light-mode values.
Almost always a color mismatch — the background, track, and thumb need to be exactly the same base color. Any fill difference breaks the extruded-from-surface illusion and it reads as a standard toggle with weird shadows.
