Neumorphism Card in React: Soft UI with Correct Contrast Ratios
Build a neumorphism card in React that actually passes WCAG contrast checks. Real CSS, real component code, and the shadow math that makes soft UI work.
What Neumorphism Actually Is (And Why Most Implementations Get It Wrong)
Neumorphism — sometimes called "soft UI" — is a design style where UI elements appear to extrude from or be pressed into the background surface. It doesn't use borders or heavy drop shadows. Instead, it uses two carefully chosen box shadows: one lighter than the background on the top-left, one darker on the bottom-right. The result looks like the element is physically molded out of the same material as the page itself.
The term got coined on Dribbble around 2019–2020 and was immediately controversial. And honestly, the backlash was at least 60% justified — most early neumorphism mockups were gorgeous in Figma and completely inaccessible in a browser. Text on a #e0e5ec background with a shadow-only differentiation between UI states? Disaster. But the visual language itself isn't broken. The implementations were.
The math behind it is simple once you see it. Pick your base background color — say #e0e5ec. Your light shadow is that color shifted toward white by about 10–15 lightness points: #ffffff or #f9f9f9. Your dark shadow shifts toward black by the same amount: #a3b1c6 or #bec8d4. The spread and blur radius control how "raised" the element feels. A 6px offset with 12px blur reads as a gentle press; 10px offset with 20px blur feels dramatically raised.
You can also invert the effect — swap which shadow goes where — and you get a pressed-in, concave look. That's what you'd use for an active/clicked state. Worth noting: this pressed variant is actually what the accessible neumorphism pattern leans on most heavily, because it changes the *shape* of the element (not just color) to communicate state.
The Shadow Formula and CSS Variables Setup
Before touching React at all, nail your CSS. The trick most tutorials skip is using CSS custom properties to keep your light/dark shadow values in sync across the whole component tree. If you hardcode the hex values in every class, you're going to have a bad time when the designer asks to tweak the base hue in 2026's fourth sprint.
/* tokens.css — or paste into :root in your global stylesheet */
:root {
--neu-bg: #e0e5ec;
--neu-shadow-light: #ffffff;
--neu-shadow-dark: #a3b1c6;
/* raised card */
--neu-shadow-raised:
6px 6px 12px var(--neu-shadow-dark),
-6px -6px 12px var(--neu-shadow-light);
/* pressed / active state */
--neu-shadow-pressed:
inset 4px 4px 8px var(--neu-shadow-dark),
inset -4px -4px 8px var(--neu-shadow-light);
/* flat / resting state (subtle) */
--neu-shadow-flat:
2px 2px 5px var(--neu-shadow-dark),
-2px -2px 5px var(--neu-shadow-light);
}The inset keyword is your best friend for interactive states. Without it you're stuck relying on color alone to communicate "this button was clicked", which fails WCAG 1.4.11 (Non-text Contrast). With inset, the element visibly collapses inward — that's a spatial change, not just a color change, which is far more accessible.
One more thing — dark mode. Neumorphism on a dark background (#1a1a2e range) works, but you need completely different shadow values. Dark neumorphism uses a slightly lighter shade of the background as the light shadow and near-black as the dark shadow. Set up a second [data-theme='dark'] block with overridden tokens and you're done. Don't try to derive dark-mode shadows algorithmically at runtime — it's not worth the complexity.
Building the React Component
Here's a typed, production-quality NeuCard component. It accepts a variant prop for raised/pressed/flat states so you can wire it directly to click handlers without managing class names in the parent.
// NeuCard.tsx
import { HTMLAttributes, ReactNode } from 'react';
import styles from './NeuCard.module.css';
type NeuVariant = 'raised' | 'pressed' | 'flat';
interface NeuCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
variant?: NeuVariant;
interactive?: boolean;
}
export function NeuCard({
children,
variant = 'raised',
interactive = false,
className = '',
...rest
}: NeuCardProps) {
return (
<div
className={[
styles.card,
styles[variant],
interactive ? styles.interactive : '',
className,
]
.filter(Boolean)
.join(' ')}
{...rest}
>
{children}
</div>
);
}/* NeuCard.module.css */
.card {
background: var(--neu-bg, #e0e5ec);
border-radius: 16px;
padding: 24px;
transition: box-shadow 0.2s ease;
}
.raised { box-shadow: var(--neu-shadow-raised); }
.pressed { box-shadow: var(--neu-shadow-pressed); }
.flat { box-shadow: var(--neu-shadow-flat); }
.interactive {
cursor: pointer;
user-select: none;
}
.interactive:hover { box-shadow: var(--neu-shadow-raised); }
.interactive:active { box-shadow: var(--neu-shadow-pressed); }
/* Focus ring — do NOT remove this */
.interactive:focus-visible {
outline: 3px solid #4f46e5;
outline-offset: 3px;
}The focus-visible ring is non-negotiable. Neumorphism cards used as buttons or links need a visible keyboard focus state that has nothing to do with shadows, because the shadow contrast at any WCAG-passing level is still too subtle for users relying on keyboard navigation. A 3px outline at 3px offset clears the 3:1 minimum ratio required by WCAG 2.4.11 easily.
In practice, you'll want to wire the interactive variant to a toggle state for things like settings toggles or filter chips. Here's the full usage pattern for a toggled card:
``tsx
function ToggleCard({ label }: { label: string }) {
const [active, setActive] = useState(false);
return (
<NeuCard
variant={active ? 'pressed' : 'raised'}
interactive
role="button"
aria-pressed={active}
tabIndex={0}
onClick={() => setActive(v => !v)}
onKeyDown={e => e.key === 'Enter' && setActive(v => !v)}
>
<span>{label}</span>
</NeuCard>
);
}
``
Quick aside: if you're building neumorphism inside a component library like Empire UI, the shadow tokens are already defined at the theme level. You don't need to redefine --neu-shadow-raised yourself — just import the style preset and drop your content inside <NeuCard>. Saves the boilerplate.
Getting Contrast Ratios Right Without Ruining the Aesthetic
This is where most neumorphism tutorials bail on you. The style is genuinely hard to make accessible because the entire look depends on low-contrast surfaces. Text on #e0e5ec in #8a8fa8 — the "natural" color that matches the shadow palette — lands around 2.8:1. That fails WCAG AA (4.5:1 for normal text, 3:1 for large text at 18px or 14px bold).
The solution that actually works: keep the background and shadows doing their sculptural job, but use a *much* darker text color than feels natural. #2d3561 on #e0e5ec clears 8:1. It looks slightly off-palette at first, and then it just... looks right. Your eye adjusts. The contrast you save for labels, values, and descriptions goes right back into the aesthetic because the card itself still reads as "soft UI".
For icons and decorative strokes inside the card, you have more room. A 24px icon at 3:1 is fine under WCAG. Use #5a6590 or similar — it's dark enough to pass large/graphic contrast, light enough to feel neumorphic. Don't sweat the secondary metadata labels (timestamps, tags) too hard, but anything that carries real information needs to hit 4.5:1.
Look, there's a pragmatic shortcut: use the box shadow generator to dial in your shadows visually, then paste the result into your CSS tokens. Once the shadows are locked, open the Chrome accessibility panel, inspect your text nodes, and bump the color until the ratio badge turns green. That workflow takes maybe 10 minutes per component and you only do it once.
One pattern worth stealing from macOS Ventura's control surfaces (circa 2023): slightly elevated cards use a 1px border at rgba(255,255,255,0.6) on the top edge. It reads as a highlight, not a border, and adds another layer of depth that lets you soften the shadow slightly — which in turn makes text contrast easier to achieve because you've reduced the visual noise competing with the type.
Dark Mode Neumorphism
Dark neumorphism is its own beast. The base color shifts to something like #1e2130 or #2d2f3e. Your light shadow becomes a slightly lighter version of that base — #3a3d52 works well. Your dark shadow goes near-black: #12141e. The effect is subtler than light-mode neu, which is actually a good thing — it's less likely to cause the "everything looks the same" readability problem.
[data-theme='dark'] {
--neu-bg: #1e2130;
--neu-shadow-light: #2e3148;
--neu-shadow-dark: #0e0f1a;
--neu-shadow-raised:
5px 5px 10px var(--neu-shadow-dark),
-5px -5px 10px var(--neu-shadow-light);
--neu-shadow-pressed:
inset 3px 3px 7px var(--neu-shadow-dark),
inset -3px -3px 7px var(--neu-shadow-light);
}Text contrast on dark neumorphism is actually *easier* to nail. White or near-white text (#e8eaf6) on #1e2130 gives you 13:1+. You have room to use lighter grays for secondary info and still clear 4.5:1. The problem shifts from "text is too dim" to "shadows are invisible" — so bump your offsets to at least 5px and your blur to 10px minimum or the sculpted effect disappears entirely.
That said, mixing light and dark neumorphism on the same page (like a modal on top of a dark sidebar) looks wrong almost every time. Pick one mode per surface and stick to it. If you need to mix, treat the modal as a glass card (glassmorphism components) over the dark neumorphic background — that combination works surprisingly well and sidesteps the style collision.
Performance and When Not to Use Neumorphism
Neumorphism is cheap in pure CSS terms. Box shadows are GPU-composited, border-radius is trivial, and there's no backdrop-filter compute cost like glassmorphism carries. You can render 200 neumorphic cards on a page without breaking a sweat on any 2024+ device.
Where it breaks down: dense information UIs. Neumorphism is a single-surface style — everything lives on the same plane extruded slightly in or out. The moment you need to communicate three or four levels of visual hierarchy (navigation, sidebar, content, modal), neumorphism runs out of vocabulary. You end up with raised cards inside raised sections and the whole depth model collapses. Use it for focused, single-purpose UIs — dashboards, settings panels, music players, calculator apps — not for content-heavy layouts.
Also don't use it for primary call-to-action buttons if your brand color is anything other than a neutral. A purple #6d28d9 button can't be neumorphic without a purple background — the shadow formula only works when element color matches background color. You can fake it, but it never looks right. For colorful CTAs, go flat or use a different shadow strategy entirely.
If you want to see the style in context before committing to it, the Empire UI neumorphism style hub has live component previews and ready-to-copy code. Worth a look before you spend an afternoon tuning shadow values manually — someone already did that work.
FAQ
It can be, but it takes deliberate effort. Use dark text (target 7:1+ on your base color), inset shadows for state changes instead of color alone, and always add a visible focus ring for keyboard users. The shadows themselves aren't the problem — it's the lazy default of using low-contrast text that makes most implementations fail.
Around 8–10px for raised states. Below that the two shadow edges start reading as a hard offset shadow rather than a soft extrusion. For pressed/inset states you can go as low as 6px blur because the inset direction gives more visual cues.
Yes, but Tailwind's built-in shadow utilities won't get you there — you need shadow-[...] arbitrary values or CSS custom properties. Define your shadow tokens in a :root block and reference them via inline styles or a thin CSS module. Don't try to encode the full dual-shadow string as a Tailwind config value; it gets messy fast.
Yes. Use a dark base (around #1e2130), a slightly lighter shade for the light shadow, and near-black for the dark shadow. Increase your offset and blur by 20–30% compared to light mode — dark neu shadows are subtler by nature and need the extra spread to read clearly.