EmpireUI
Get Pro
← Blog8 min read#neumorphism#toggle#switch

Neumorphism Toggle Switch in CSS and React: Soft UI Done Properly

Build a neumorphism toggle switch with pure CSS and React — the soft UI effect that's trickier than it looks, done right without the common accessibility pitfalls.

Soft white neumorphic toggle switch UI component on gray background

What Makes Neumorphism Actually Difficult

Neumorphism looks simple. It's just shadows, right? Two box-shadows — one light, one dark — on a surface that matches the background. You've seen the Dribbble shots. But when you sit down to build it, particularly something interactive like a toggle switch, things get annoying fast.

The core problem is that neumorphism is background-dependent in a way that no other UI style is. Glassmorphism can sit on anything. Neobrutalism doesn't care. Neumorphism absolutely needs the element's background to match the page background, because the shadows are supposed to look like the surface is *extruded from* that background. The moment you change the background color — dark mode, different section, whatever — you have to recalculate your whole shadow stack. Every single time.

That said, a toggle switch is the perfect component to start with because the state change (on/off) gives you a natural reason to swap shadow directions. The 'pressed in' feel for the off state and the 'popped out' feel for the on state maps exactly to what neumorphism does best. So yes, it's fiddly, but the end result actually makes sense as a UI metaphor.

Honestly, if you've been reading the glassmorphism vs neumorphism debate, the toggle switch is the component where neumorphism wins. It telegraphs state changes through depth cues in a way frosted glass can't.

The CSS Foundation: Shadows, Surfaces, and the 145deg Rule

Before you write a single line of React, you need to nail the pure CSS version. Let's set a base background of #e0e5ec — that 2019-era light gray that every neumorphism tutorial uses, and for good reason. Your light shadow goes top-left at roughly 145deg, your dark shadow goes bottom-right.

Here's the base track (the groove the thumb slides through): ``css .neu-track { background: #e0e5ec; border-radius: 50px; width: 56px; height: 28px; box-shadow: -4px -4px 8px rgba(255, 255, 255, 0.8), 4px 4px 8px rgba(163, 177, 198, 0.6); position: relative; cursor: pointer; transition: box-shadow 0.2s ease; } `` That outer convex shadow makes the track look raised. When the toggle is 'on', you invert to an inset shadow to make it look pressed in — like you've pushed the surface down.

.neu-track.is-on {
  box-shadow:
    inset -3px -3px 6px rgba(255, 255, 255, 0.7),
    inset  3px  3px 6px rgba(163, 177, 198, 0.5);
}
```

The thumb itself needs its own separate shadow pair — don't just let it inherit. The thumb should always feel slightly raised off the track surface, regardless of state:

```css
.neu-thumb {
  position: absolute;
  top: 4px;
  left: 4px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #e0e5ec;
  box-shadow:
    -2px -2px 5px rgba(255, 255, 255, 0.9),
     2px  2px 5px rgba(163, 177, 198, 0.7);
  transition: left 0.2s ease, box-shadow 0.2s ease;
}

.neu-track.is-on .neu-thumb {
  left: 32px; /* 56px track - 20px thumb - 4px right gap */
}

Worth noting: a 56px wide track with a 20px thumb gives you 32px of travel (56 - 20 - 4 right margin). That 4px padding on each side keeps the thumb from looking like it's about to fall off. You can scale the whole thing proportionally for different sizes, but 28px height is where it starts reading as a proper control rather than a decorative blob.

Building the React Component

The CSS version is fine for demos. In practice, you want a React component with proper state, aria attributes, and keyboard support. Here's a version that doesn't cut corners: ``tsx import { useState } from 'react'; import './NeuToggle.css'; interface NeuToggleProps { defaultChecked?: boolean; onChange?: (checked: boolean) => void; label?: string; disabled?: boolean; } export function NeuToggle({ defaultChecked = false, onChange, label, disabled = false, }: NeuToggleProps) { const [checked, setChecked] = useState(defaultChecked); const toggle = () => { if (disabled) return; const next = !checked; setChecked(next); onChange?.(next); }; return ( <label className="neu-toggle-wrapper"> {label && ( <span className="neu-toggle-label">{label}</span> )} <button type="button" role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={toggle} onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(); } }} className={[ 'neu-track', checked ? 'is-on' : '', disabled ? 'is-disabled' : '', ] .filter(Boolean) .join(' ')} > <span className="neu-thumb" aria-hidden="true" /> </button> </label> ); } ``

A few things you'd want to notice here. The outer element is a <label> — that means clicking the text label also toggles the switch without extra event plumbing. The button has role="switch" and aria-checked, which is the ARIA pattern for this exact control. Screen readers will announce 'notifications, switch, on' or whatever your label is. That's the correct behavior.

One more thing — the disabled prop disables keyboard events at the DOM level via the native disabled attribute on the button, but you also guard it in the toggle function. That double-guard isn't paranoia; it handles programmatic calls from parent components that might bypass the DOM event.

Quick aside: if you're on React 18 or earlier and you want a controlled version instead of uncontrolled, just swap useState for a checked prop and remove the internal state. The component is structured to make that refactor straightforward — toggle already calls onChange before touching internal state.

The full CSS that goes with this component: ``css /* NeuToggle.css */ .neu-toggle-wrapper { display: inline-flex; align-items: center; gap: 12px; cursor: pointer; user-select: none; } .neu-toggle-label { font-size: 14px; color: #6b7a8d; font-weight: 500; } .neu-track { background: #e0e5ec; border-radius: 50px; width: 56px; height: 28px; box-shadow: -4px -4px 8px rgba(255, 255, 255, 0.8), 4px 4px 8px rgba(163, 177, 198, 0.6); position: relative; cursor: pointer; border: none; padding: 0; transition: box-shadow 0.2s ease; } .neu-track.is-on { box-shadow: inset -3px -3px 6px rgba(255, 255, 255, 0.7), inset 3px 3px 6px rgba(163, 177, 198, 0.5); } .neu-track.is-disabled { opacity: 0.5; cursor: not-allowed; } .neu-track:focus-visible { outline: 2px solid #5b86e5; outline-offset: 3px; } .neu-thumb { position: absolute; top: 4px; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: #e0e5ec; box-shadow: -2px -2px 5px rgba(255, 255, 255, 0.9), 2px 2px 5px rgba(163, 177, 198, 0.7); transition: left 0.2s ease, box-shadow 0.2s ease; pointer-events: none; } .neu-track.is-on .neu-thumb { left: 32px; } ``

Adding Color to the 'On' State Without Breaking the Effect

A pure gray neumorphic toggle is technically correct but visually useless. Users can't tell which state is 'on' at a glance. You need color in the 'on' state — but this is where a lot of implementations fall apart.

The wrong approach: changing the track's background color when it's on. You'd need to recalculate your shadow colors to match the new background, because remember, neumorphism's shadows need to match the surface. That doubles your color variables and makes theming a nightmare.

The right approach: use a colored overlay or an inset gradient instead of changing the base background. Or better — tint just the thumb: ``css .neu-track.is-on .neu-thumb { left: 32px; background: linear-gradient(145deg, #667eea, #764ba2); box-shadow: -1px -1px 3px rgba(255, 255, 255, 0.3), 2px 2px 5px rgba(102, 126, 234, 0.5); } `` A colored thumb on a still-gray track reads clearly as 'on' without forcing you to recalculate the track shadows. The shadow on the colored thumb just changes to use the thumb color's darkened version instead of the generic gray.

In practice, if you want the full track to change color, scope it like this: create a --neu-accent CSS custom property, and when is-on is active, switch the background and recalculate shadows using the same hue but lighter/darker variants: ``css :root { --neu-bg: #e0e5ec; --neu-light: rgba(255, 255, 255, 0.8); --neu-dark: rgba(163, 177, 198, 0.6); --neu-accent: #667eea; --neu-accent-light: rgba(102, 126, 234, 0.3); --neu-accent-dark: rgba(50, 70, 180, 0.5); } .neu-track.is-on { background: var(--neu-accent); box-shadow: inset -3px -3px 6px var(--neu-accent-light), inset 3px 3px 6px var(--neu-accent-dark); } ` That pattern scales. You can swap out --neu-accent` in a theme class and all your neumorphic toggles update.

Accessibility: The Part Everyone Skips

Look, neumorphism has a real accessibility problem. The low-contrast soft shadows that make the effect look premium are actively hostile to users with low vision. WCAG 2.2 requires a 3:1 contrast ratio for UI component boundaries, and a light gray shadow on a light gray background barely hits 1.5:1 on a good day.

There are two strategies that don't wreck the aesthetic. First, add a subtle 1px solid border with higher contrast: ``css .neu-track { /* ...existing styles... */ border: 1px solid rgba(163, 177, 198, 0.4); } ` That border adds about 0.5 contrast ratio units and gives the control a visible edge that browser zoom doesn't destroy. Second — and this one is free — make sure your focus ring is solid and visible. The :focus-visible rule in the CSS above uses a 2px solid #5b86e5 at 3px offset`. That blue ring at 3px distance gives enough separation from the component that it doesn't visually merge.

Also: never rely on color alone to indicate the on/off state. If you're using a colored thumb for 'on', also move it. That's what the translation does. But if you're building something where the thumb position is subtle (very small toggle, etc.), add an explicit text state somewhere nearby — 'On' / 'Off' label text that updates alongside the component. Screen readers will get aria-checked, but sighted low-vision users navigating without a reader also need a non-color cue.

Worth noting: the [neumorphism](/neumorphism) components in the Empire UI library ship with these contrast fixes baked in, so if you want ready-made soft-UI components that don't fail accessibility audits out of the box, that's the faster path.

Dark Mode: The Neumorphism Graveyard

Most neumorphism implementations die in dark mode. The white highlight shadow (rgba(255,255,255,0.8)) becomes a blinding blob on dark surfaces. And the dark accent shadow (rgba(163,177,198,0.6)) disappears entirely because it's lighter than a dark background.

Dark mode neumorphism requires completely different shadow colors. You're no longer using a white highlight — you're using a slightly lighter version of your dark background. And your dark shadow becomes a deeper version of that background: ``css @media (prefers-color-scheme: dark) { :root { --neu-bg: #1e2130; --neu-light: rgba(40, 50, 80, 0.8); /* lighter than bg */ --neu-dark: rgba(10, 12, 20, 0.9); /* darker than bg */ } .neu-track { background: var(--neu-bg); box-shadow: -4px -4px 8px var(--neu-light), 4px 4px 8px var(--neu-dark); } .neu-track.is-on { box-shadow: inset -3px -3px 6px var(--neu-light), inset 3px 3px 6px var(--neu-dark); } .neu-thumb { background: var(--neu-bg); box-shadow: -2px -2px 5px var(--neu-light), 2px 2px 5px var(--neu-dark); } } ` The key insight: light shadow = *slightly lighter than background*, dark shadow = *noticeably darker than background*. On light themes the 'lighter' direction happens to be white. On dark themes white is completely wrong — you want something like rgba(50, 60, 90, 0.8)` for a midnight blue base.

In practice this means maintaining two sets of shadow values. Not fun, but that's the cost of the effect. If maintaining both feels too heavy, consider CSS custom properties set via a [data-theme] attribute on <html> so you can override them with JavaScript rather than media queries — that way user-controlled dark mode toggles work correctly too.

Alternatively, look at whether a different style serves your needs better. If you're building something that has to work on multiple background colors, glassmorphism components are much more forgiving. They layer on backgrounds rather than extruding from them.

Putting It Together: Reusable Component with Variants

Here's the production-ready version with size and color variants, combining everything above: ``tsx // NeuToggle.tsx — full production version import { useState } from 'react'; type ToggleSize = 'sm' | 'md' | 'lg'; type ToggleColor = 'blue' | 'green' | 'purple'; const SIZE_CONFIG: Record<ToggleSize, { track: string; thumb: string; travel: string; }> = { sm: { track: 'w-10 h-5', thumb: 'w-3 h-3 top-1 left-1', travel: 'translate-x-5' }, md: { track: 'w-14 h-7', thumb: 'w-5 h-5 top-1 left-1', travel: 'translate-x-7' }, lg: { track: 'w-16 h-8', thumb: 'w-6 h-6 top-1 left-1', travel: 'translate-x-8' }, }; const COLOR_MAP: Record<ToggleColor, string> = { blue: 'bg-blue-500 shadow-blue-700/50', green: 'bg-green-500 shadow-green-700/50', purple: 'bg-purple-500 shadow-purple-700/50', }; interface NeuToggleProps { size?: ToggleSize; color?: ToggleColor; defaultChecked?: boolean; onChange?: (v: boolean) => void; label?: string; disabled?: boolean; } export function NeuToggle({ size = 'md', color = 'blue', defaultChecked = false, onChange, label, disabled = false, }: NeuToggleProps) { const [on, setOn] = useState(defaultChecked); const { track, thumb, travel } = SIZE_CONFIG[size]; const toggle = () => { if (disabled) return; const next = !on; setOn(next); onChange?.(next); }; return ( <label className="inline-flex items-center gap-3 select-none cursor-pointer"> <button type="button" role="switch" aria-checked={on} aria-label={label} disabled={disabled} onClick={toggle} className={[ track, 'relative rounded-full border border-white/20', 'transition-all duration-200', 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2', on ? ${COLOR_MAP[color]} shadow-[inset_-2px_-2px_5px_rgba(255,255,255,0.2),inset_2px_2px_5px_rgba(0,0,0,0.3)] : 'bg-[#e0e5ec] shadow-[-4px_-4px_8px_rgba(255,255,255,0.8),4px_4px_8px_rgba(163,177,198,0.6)]', disabled ? 'opacity-50 cursor-not-allowed' : '', ].join(' ')} > <span aria-hidden="true" className={[ thumb, 'absolute rounded-full', 'bg-[#e0e5ec]', 'shadow-[-2px_-2px_5px_rgba(255,255,255,0.9),2px_2px_5px_rgba(163,177,198,0.7)]', 'transition-transform duration-200', on ? travel : '', ].join(' ')} /> </button> {label && ( <span className="text-sm font-medium text-[#6b7a8d]">{label}</span> )} </label> ); } ``

This version uses Tailwind for layout utilities but keeps the neumorphic shadow values as inline Tailwind arbitrary values — which means you're not fighting PostCSS to extend the shadow config. The size config object handles the travel distance calculation at definition time so there's no runtime math.

One more thing — if you're building a settings panel or a form where multiple toggles appear together, wrap them in a fieldset with a legend. That groups them semantically for screen readers and gives you a natural place to add gap-4 or whatever spacing you need. Individual aria-label values on each toggle still apply — the fieldset legend is additive, not a replacement.

FAQ

Why does my neumorphism toggle look flat in dark mode?

Because you're still using white highlights. Dark mode needs shadows that are lighter/darker variants of your dark background color — not white. Replace rgba(255,255,255,0.8) with something like rgba(50,60,90,0.8) to match your specific dark base color.

What's the correct ARIA pattern for a toggle switch?

Use role="switch" with aria-checked on a <button> element. Don't use role="checkbox" — that implies a form submission behavior that toggles don't have. The switch role is specifically for this binary on/off control pattern.

Can I use neumorphism on a non-gray background?

Yes, but your shadow colors must be derived from that background. Use a tool like the box shadow generator to calculate the correct light and dark shadow tints for your specific background hex value.

Is neumorphism accessible at all, or should I avoid it?

It can pass accessibility requirements if you add a visible 1px border for component boundaries, ensure your focus ring meets WCAG 2.2's 3:1 minimum, and never rely on color alone for state indication. It's extra work but it's doable.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Neumorphism Switch Toggle: Soft UI On/Off With CSS OnlyNeumorphism Button Design: Soft UI Done RightNeumorphism in Tailwind CSS: Soft Shadows Without the Opacity TrapBest CSS Animation Libraries in 2026: Motion, GSAP, Auto-Animate