
Neumorphism in CSS: Soft UI Design Techniques
Neumorphism, also called soft UI, is the style where interface elements look like they've been molded out of the background material itself. Buttons bulge gently out of the surface, inputs look pressed into it, and everything shares one soft, matte color. It borrows from skeuomorphism's sense of physical depth but keeps the minimal, flat color palette of modern design.
The whole effect is built from one CSS property: box-shadow. In this guide, we'll break down the lighting model that makes neumorphism work, build buttons, inputs, toggles, and cards, and then talk honestly about its biggest weakness, accessibility, and how to work around it.
The Lighting Model
Neumorphism is based on a simple physical idea: a single light source, usually at the top-left, shining on a surface of one uniform color. A raised shape on that surface would:
- Catch light on its top-left edge, creating a highlight lighter than the surface.
- Cast a shadow on its bottom-right, darker than the surface.
In CSS, that's two shadows on the same element, pointing in opposite directions:
.raised {
background: #e0e5ec;
box-shadow:
8px 8px 16px #a3b1c6,
-8px -8px 16px #ffffff;
}
The first shadow is offset down and to the right in a darker tone. The second is offset up and to the left in a lighter tone. With a matching background on both the element and its parent, the element appears to rise out of the page.
The golden rule: the element's background must match the surface behind it. If the colors differ, the illusion breaks and you just have a box with odd shadows.
Choosing Colors
Neumorphism needs a mid-tone base color. Pure white leaves no room for a lighter highlight, and pure black leaves no room for a darker shadow. Soft grays, blues, and pastels work best.
Here's a set of custom properties that makes the whole system easy to adjust:
:root {
--surface: #e0e5ec;
--shadow-dark: #a3b1c6;
--shadow-light: #ffffff;
--text: #3d4a5c;
--accent: #4f7cff;
--distance: 8px;
--blur: 16px;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background: var(--surface);
font-family: system-ui, sans-serif;
}
Deriving Shadows with Relative Color Syntax
Picking the dark and light shadow colors by hand for every theme gets tedious. With relative color syntax, you can derive them from the surface color automatically:
:root {
--surface: #e0e5ec;
--shadow-dark: oklch(from var(--surface) calc(l - 0.18) c h);
--shadow-light: oklch(from var(--surface) calc(l + 0.08) c h);
}
This takes the surface color, converts it to OKLCH, and adjusts only the lightness. Change --surface to a pastel blue and both shadows follow. Relative color syntax is supported in current versions of all major browsers, but older browsers will discard the declaration. If you need to support them, define the hex values first and override them inside @supports (color: oklch(from red l c h)).
Building the Core Shapes
Neumorphic interfaces rely on three basic states.
Flat (Raised)
The default: an element that sits slightly above the surface.
.neu-flat {
background: var(--surface);
border-radius: 16px;
box-shadow:
var(--distance) var(--distance) var(--blur) var(--shadow-dark),
calc(var(--distance) * -1) calc(var(--distance) * -1) var(--blur)
var(--shadow-light);
}
Pressed (Inset)
An element sunk into the surface, created by flipping both shadows to inset:
.neu-pressed {
background: var(--surface);
border-radius: 16px;
box-shadow:
inset var(--distance) var(--distance) var(--blur) var(--shadow-dark),
inset calc(var(--distance) * -1) calc(var(--distance) * -1) var(--blur)
var(--shadow-light);
}
Notice the directions stay the same. With inset, the dark shadow now appears inside the top-left edge, as if that edge blocks the light, and the highlight appears inside the bottom-right. That's exactly how a physical depression looks.
Convex and Concave
For a more pronounced curved look, add a subtle gradient to the background:
.neu-convex {
border-radius: 16px;
background: linear-gradient(145deg, #f0f5fc, #cacfd4);
box-shadow:
var(--distance) var(--distance) var(--blur) var(--shadow-dark),
calc(var(--distance) * -1) calc(var(--distance) * -1) var(--blur)
var(--shadow-light);
}
.neu-concave {
border-radius: 16px;
background: linear-gradient(145deg, #cacfd4, #f0f5fc);
box-shadow:
var(--distance) var(--distance) var(--blur) var(--shadow-dark),
calc(var(--distance) * -1) calc(var(--distance) * -1) var(--blur)
var(--shadow-light);
}
The convex version is lighter at the top-left, like a dome catching light. The concave version reverses it, like a shallow bowl.
Components
Buttons
Buttons are where neumorphism feels most natural, because the raised-to-pressed transition mimics a real physical button.
<button class="neu-button" type="button">Play</button>
.neu-button {
padding: 1rem 2rem;
border: 0;
border-radius: 14px;
color: var(--text);
font: inherit;
font-weight: 600;
background: var(--surface);
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
cursor: pointer;
transition:
box-shadow 0.2s ease,
color 0.2s ease;
}
.neu-button:hover {
color: var(--accent);
}
.neu-button:active {
box-shadow:
inset 6px 6px 12px var(--shadow-dark),
inset -6px -6px 12px var(--shadow-light);
}
.neu-button:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 4px;
}
Transitioning between outer and inset shadows doesn't interpolate smoothly in every case, because an inset shadow can't morph into an outer one. The browser will switch between them. If you want a smooth press, transition to a smaller outer shadow first, or keep both states consistent (all outer or all inset) with different distances.
A smoother alternative uses four shadows, two outer and two inset, and animates their values instead of their type:
.neu-button-smooth {
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light),
inset 0 0 0 var(--shadow-dark),
inset 0 0 0 var(--shadow-light);
transition: box-shadow 0.2s ease;
}
.neu-button-smooth:active {
box-shadow:
0 0 0 var(--shadow-dark),
0 0 0 var(--shadow-light),
inset 6px 6px 12px var(--shadow-dark),
inset -6px -6px 12px var(--shadow-light);
}
Because both lists have the same number of shadows with matching inset keywords at each position, the browser can interpolate every value.
Text Inputs
Inputs look natural pressed into the surface:
<label class="neu-field">
<span class="neu-field__label">Email</span>
<input class="neu-input" type="email" placeholder="you@example.com" />
</label>
.neu-field {
display: grid;
gap: 0.5rem;
max-width: 20rem;
}
.neu-field__label {
font-size: 0.875rem;
font-weight: 600;
}
.neu-input {
padding: 0.9rem 1.1rem;
border: 0;
border-radius: 12px;
color: var(--text);
font: inherit;
background: var(--surface);
box-shadow:
inset 4px 4px 8px var(--shadow-dark),
inset -4px -4px 8px var(--shadow-light);
}
.neu-input::placeholder {
color: #6b7a90;
}
.neu-input:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
A Toggle Switch
A checkbox styled as a switch shows off both raised and pressed shapes together:
<label class="neu-toggle">
<input type="checkbox" class="neu-toggle__input" />
<span class="neu-toggle__track" aria-hidden="true"></span>
<span class="neu-toggle__text">Dark mode</span>
</label>
.neu-toggle {
display: inline-flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.neu-toggle__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.neu-toggle__track {
position: relative;
width: 64px;
height: 34px;
border-radius: 999px;
background: var(--surface);
box-shadow:
inset 3px 3px 6px var(--shadow-dark),
inset -3px -3px 6px var(--shadow-light);
}
.neu-toggle__track::after {
content: "";
position: absolute;
top: 4px;
left: 4px;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--surface);
box-shadow:
3px 3px 6px var(--shadow-dark),
-3px -3px 6px var(--shadow-light);
transition:
transform 0.25s ease,
background-color 0.25s ease;
}
.neu-toggle__input:checked + .neu-toggle__track::after {
transform: translateX(30px);
background: var(--accent);
}
.neu-toggle__input:focus-visible + .neu-toggle__track {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
The track is pressed in, the knob is raised, and the accent color on the checked state makes the on/off status clear without relying only on position.
Cards
Cards are the simplest component: a raised surface with generous padding and radius.
.neu-card {
padding: 2rem;
border-radius: 24px;
background: var(--surface);
box-shadow:
12px 12px 24px var(--shadow-dark),
-12px -12px 24px var(--shadow-light);
}
A useful rule of thumb: the blur radius should be about twice the offset distance, and larger elements need larger distances. A 64px button might use 6px offsets, while a 400px card looks right with 12px to 20px.
Dark Mode Neumorphism
Soft UI works on dark surfaces too. The highlight becomes a slightly lighter gray rather than white:
@media (prefers-color-scheme: dark) {
:root {
--surface: #2a2d33;
--shadow-dark: #1c1e22;
--shadow-light: #383c44;
--text: #d6dbe3;
--accent: #7aa2ff;
}
}
Keep the highlight subtle. On dark backgrounds, a bright highlight quickly looks like a glowing border rather than reflected light.
The Accessibility Problem
Neumorphism has a well-known weakness: low contrast. The entire style relies on elements being the same color as their background, separated only by soft shadows. For people with low vision, on low-quality screens, or in bright sunlight, those shadows can disappear, and buttons become invisible.
WCAG requires a 3:1 contrast ratio for the visual boundaries of interactive components. Soft shadows on matching backgrounds rarely achieve that. Here's how to keep soft UI usable:
- Use neumorphism for decoration, not affordance. Make sure buttons are identifiable by their label, icon, or an accent color, not only by their shadow.
- Give primary actions a real color. A solid accent background on the main call to action ensures it stands out.
- Always show visible focus styles. A clear outline is non-negotiable for keyboard users.
- Add a subtle border when needed. A 1px border slightly darker than the surface helps define edges without ruining the look.
- Support high contrast preferences:
@media (prefers-contrast: more) {
:root {
--shadow-dark: #6b7a90;
}
.neu-button,
.neu-input,
.neu-card {
border: 1px solid #3d4a5c;
}
}
@media (forced-colors: active) {
.neu-button,
.neu-input {
border: 1px solid ButtonText;
}
}
In forced colors mode, box shadows are removed entirely, so borders are the only thing that keeps controls visible.
- Check text contrast separately. Text color on the surface must still meet 4.5:1 for normal text. Soft gray on soft gray often fails.
Where Neumorphism Works (and Where It Doesn't)
Soft UI suits interfaces with few elements and clear hierarchy, like:
- Music players and media controls
- Smart home dashboards and settings panels
- Calculator and timer apps
- Portfolio pieces and concept designs
It struggles in dense, content-heavy interfaces like data tables, long forms, and e-commerce listings, where users need to scan quickly and many elements compete for attention. In those contexts, use neumorphic touches sparingly, for example on a single hero card or a set of controls, rather than across the whole interface.
Performance Notes
Multiple large, blurred box shadows are relatively cheap to paint for static elements, but animating them on many elements at once can cost frame time, especially on low-end devices. If you animate lots of neumorphic cards, consider animating transform on the element and keeping shadows static, or fading a pseudo-element that holds the pressed shadow with opacity, which is cheaper to composite.
Conclusion
Neumorphism is a clever use of box-shadow: two shadows, one light and one dark, on an element that matches its background, and you get a surface that looks physically molded. Flip them to inset and it's pressed in. Add a gradient and it becomes convex or concave. Custom properties and relative color syntax make the whole system easy to theme.
The style's softness is also its limitation. Use it where interfaces are simple, pair it with accent colors and strong focus states, and add borders for high contrast users. Treated as a finishing touch rather than the only way to show structure, soft UI can make an interface feel tactile and calm without leaving anyone behind.


