Neumorphism Button Design: Soft UI Done Right
Learn how to build neumorphism buttons that actually work — correct shadows, accessible states, and React code you can drop straight into your project.
What Neumorphism Actually Is (And Why Buttons Are the Hardest Part)
Neumorphism appeared around 2019 and broke Twitter for about a week. Everyone either loved the soft, extruded look or called it an accessibility disaster. Both camps had a point. The style mimics physical surfaces — elements appear to be pushed out of or pressed into a background, using dual box shadows to fake depth.
Buttons are where neumorphism gets tricky. A flat button has one job: look clickable. A neumorphic button needs to convey three states — resting (raised), pressed (inset), and focused — while still reading as interactive. Most tutorials skip the pressed state entirely, which is exactly why their demos look polished but feel broken the moment you actually click anything.
The core mechanic is two opposing shadows: one light (top-left), one dark (bottom-right), both matching the background hue. Get the background wrong by even 5% lightness and the illusion collapses entirely. That's not a margin for error — it's a constraint. You're essentially painting with shadows on a single-color canvas.
If you want to see the style done right across a full component set, the neumorphism hub on Empire UI has ready-built primitives that handle all the edge cases covered here.
The Shadow Math Behind a Neumorphic Button
Two shadows, one background. That's the whole formula. The light shadow sits at a negative offset (top-left origin) and uses a lightened version of your background. The dark shadow sits at a positive offset (bottom-right) and uses a darkened version. The blur radius determines how soft the extrusion feels.
For a background of #e0e5ec, a well-tuned resting button looks like this — a 6px offset on each axis, 12px blur, no spread. Go beyond 20px blur and you lose the precision that makes the effect read as depth rather than glow.
.neu-button {
background: #e0e5ec;
border-radius: 12px;
border: none;
padding: 14px 28px;
font-size: 15px;
font-weight: 600;
color: #6b7280;
cursor: pointer;
box-shadow:
6px 6px 12px #b8bec7,
-6px -6px 12px #ffffff;
transition: box-shadow 0.15s ease, transform 0.1s ease;
}
.neu-button:active {
box-shadow:
inset 4px 4px 8px #b8bec7,
inset -4px -4px 8px #ffffff;
transform: scale(0.98);
}
.neu-button:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 3px;
}Worth noting: the inset keyword on :active is the entire pressed-state trick. It flips the shadows inward, making the button look like it's been pushed into the surface. The transform: scale(0.98) isn't strictly neumorphic — it's just good UX. Without it, the transition from raised to inset feels ambiguous.
Building a Reusable Neumorphic Button in React
Hardcoding shadow values into a single CSS class doesn't scale. The moment your design system has more than one background color, you're stuck. A better move is to pass the base color as a prop and generate the shadows programmatically.
Here's a minimal React component that does exactly that — no external libraries, just CSS custom properties set inline:
function lighten(hex, amount) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, (num >> 16) + amount);
const g = Math.min(255, ((num >> 8) & 0xff) + amount);
const b = Math.min(255, (num & 0xff) + amount);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
function darken(hex, amount) {
return lighten(hex, -amount);
}
export function NeuButton({ children, base = '#e0e5ec', onClick }) {
const light = lighten(base, 24);
const dark = darken(base, 24);
const style = {
'--neu-light': light,
'--neu-dark': dark,
'--neu-base': base,
};
return (
<button
className="neu-btn"
style={style}
onClick={onClick}
>
{children}
</button>
);
}.neu-btn {
background: var(--neu-base);
box-shadow:
6px 6px 12px var(--neu-dark),
-6px -6px 12px var(--neu-light);
border: none;
border-radius: 12px;
padding: 14px 28px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.15s ease;
}
.neu-btn:active {
box-shadow:
inset 4px 4px 8px var(--neu-dark),
inset -4px -4px 8px var(--neu-light);
}
.neu-btn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 3px;
}Honestly, this lighten/darken approach is naive — it doesn't account for hue shift at high lightness values. For production work you'd want a proper color library like chroma-js or tinycolor2. But for a design system built around a single brand surface color, it's completely fine.
Accessibility: The Part Everyone Skips
Neumorphism and accessibility have a reputation. Most implementations fail WCAG 2.1 contrast requirements because the button text sits on a background that's only marginally different in lightness. That's not a style problem — it's a choices problem. You can have both.
Keep your shadow colors doing the depth work and let your typography carry the contrast. A #374151 text color on a #e0e5ec background gives you a contrast ratio of around 7.2:1. That clears AA and AAA. Don't drop below #6b7280 for body-size button labels — at 14px you'll fail AA at that gray.
The focus-visible rule in every example above isn't optional. Neumorphic buttons strip the default browser outline, which means keyboard users lose their navigation landmark entirely if you don't replace it. A 2px outline in your brand color at 3px offset costs you nothing and makes your button usable for everyone.
Quick aside: NVDA and VoiceOver don't care what your button looks like. Make sure your <button> element is a real <button>, not a styled <div>. Neumorphism tempts people into div-soup for some reason. Resist it.
Dark Mode Neumorphism: Flipping the Shadow Logic
Dark neumorphism trips people up because the instinct is to just swap background colors and invert. It doesn't work that way. On a dark surface, the light shadow needs to be a semi-transparent white, not a lightened version of the background — otherwise the contrast is too low to read as depth.
For a #1e2130 dark base, try rgba(255,255,255,0.05) for the light shadow and rgba(0,0,0,0.4) for the dark shadow. The depth reads clearly but stays subtle enough to feel premium rather than heavy.
.neu-btn--dark {
background: #1e2130;
color: #c9d1d9;
box-shadow:
6px 6px 12px rgba(0, 0, 0, 0.4),
-6px -6px 12px rgba(255, 255, 255, 0.05);
}
.neu-btn--dark:active {
box-shadow:
inset 4px 4px 8px rgba(0, 0, 0, 0.4),
inset -4px -4px 8px rgba(255, 255, 255, 0.05);
}In practice, dark neumorphism works better in small doses — icon buttons, toggle switches, card surfaces. Full-page dark neumorphism layouts start to feel muddy by 2024 standards. Use it as an accent treatment, not a system-wide style.
When to Use Neumorphic Buttons (And When Not To)
Neumorphism shines in dashboard UIs, settings panels, audio/music players, and health or wellness apps — anywhere the design language benefits from physical, tactile metaphors. The style says "premium hardware" in a way flat design can't.
It falls apart in content-heavy interfaces. If you're building an e-commerce product page, a news site, or anything with dense typography and multiple competing CTAs, neumorphic buttons add visual noise without clarity. The depth cues fight with everything else on the page for attention.
Look, the style also doesn't mix well with bright accent palettes. Neumorphism is a monochromatic technique — it depends on surface color uniformity. Drop a vivid #f97316 button into a neumorphic layout and the whole thing reads incoherent. If you want color, reach for glassmorphism components or something from the neobrutalism family instead.
One more thing — neumorphic buttons are expensive at scale. Each element carries two box shadows, and on mobile hardware with a lot of animated elements you'll feel it in paint performance. Test on a mid-range Android before committing a neumorphic design system to production.
Combining Neumorphism with Modern Tooling
Tailwind CSS doesn't ship neumorphic utilities out of the box, but extending your config is three lines. You'd add the shadow pairs to theme.extend.boxShadow and reference them as shadow-neu-raised and shadow-neu-inset across your markup.
// tailwind.config.js
module.exports = {
theme: {
extend: {
boxShadow: {
'neu-raised': '6px 6px 12px #b8bec7, -6px -6px 12px #ffffff',
'neu-inset': 'inset 4px 4px 8px #b8bec7, inset -4px -4px 8px #ffffff',
},
},
},
};That's it. Your JSX becomes <button className="bg-[#e0e5ec] shadow-neu-raised active:shadow-neu-inset rounded-xl px-7 py-3.5 font-semibold"> and you're done. Clean, predictable, easy to override per-component.
If you want a head start beyond raw CSS, browse the components on Empire UI — there are neumorphic card, input, and button variants with dark mode support already wired up. Saves you the shadow-tuning iteration, which is genuinely tedious to get right from scratch. The box shadow generator is also worth a bookmark for dialing in exactly the right depth for your specific background color.
FAQ
Almost always a background color mismatch. Your shadow colors need to be derived from the exact same hue as your surface — lighten and darken the base color, don't pick arbitrary grays. Even a 10° hue shift will kill the illusion.
Yes, if you treat contrast as a typography problem rather than a shadow problem. Keep button text at #374151 or darker on light surfaces and you'll clear AA. The shadow effect itself is decorative and doesn't affect contrast scoring.
Switch your box-shadow to inset on :active. That flips the depth cue from raised to recessed and gives immediate tactile feedback. Pairing it with transform: scale(0.98) makes the interaction feel more physical.
Yes — extend theme.boxShadow in your tailwind.config.js with your raised and inset shadow pairs, then reference them as utility classes. The built-in Tailwind shadows aren't tuned for neumorphism so don't rely on those.