Neumorphism Calculator: Soft UI Mathematical Interface
Build a neumorphism calculator with React and Tailwind — soft shadows, tactile buttons, and real arithmetic logic baked into one elegant Soft UI component.
Why a Calculator Is the Perfect Neumorphism Demo
Honestly, no UI element shows off the neumorphic aesthetic better than a calculator. Buttons that look like they're pressed into the surface. A display that appears recessed into a panel. The whole thing is one giant showcase for box-shadow layering — and a calculator gives you enough interactive states to actually prove the style works under real use.
The neumorphic design language lives and dies on one rule: everything is carved out of, or extruded from, the *same* base material. That means your button background color must exactly match the surrounding panel. Deviate by even 5% lightness and the illusion breaks. A calculator's grid of uniform buttons is the clearest possible proof that you've nailed the base color.
There's a second reason calculators make great demos. They have real state. You need pressed styles, active highlights, error states. That forces you to think about neumorphism as a design *system* rather than a static screenshot.
The Shadow Math Behind Soft UI Buttons
Neumorphism relies on exactly two shadows: one bright highlight in the top-left, one darker shadow in the bottom-right. The base color sits between them in luminance. If your base is #e0e5ec, your light shadow might be rgba(255,255,255,0.8) and your dark shadow rgba(163,177,198,0.6). No gradients. No border tricks. Just shadows.
The offset and blur values matter a lot. For a calculator button at roughly 60×60px, an offset of 4px with a 8px blur reads clearly at normal viewing distance. Go bigger — say 10px offset, 20px blur — and it starts looking like a drop shadow on a 2010 website. Stay restrained. The style is called *soft* UI for a reason.
Pressed state flips the whole model. You invert the shadows — light comes from bottom-right, dark from top-left — and you typically cut the shadow spread in half. Some implementations also add a very subtle inset shadow layer. The result is a button that looks physically depressed into the surface, which is deeply satisfying to interact with. Check out how glassmorphism vs neumorphism handles the same pressed-state problem differently — glass relies on opacity shifts, soft UI relies purely on shadow direction.
Project Setup: React + Tailwind v4.0.2
We're using Tailwind v4.0.2 with the new CSS-first config approach. The custom shadows you need for neumorphism aren't in the default theme, so you'll define them directly in your CSS file with @theme. No more tailwind.config.js object juggling.
Start with npx create-next-app@latest neucalc --typescript --tailwind then open app/globals.css. You'll add four custom utilities: neu-surface, neu-button, neu-button-pressed, and neu-display. The base color token drives everything else — change one variable and the entire calculator repaints.
/* globals.css — Tailwind v4 CSS-first config */
@import 'tailwindcss';
@theme {
--color-neu-base: #e0e5ec;
--shadow-neu-out:
6px 6px 12px rgba(163, 177, 198, 0.6),
-6px -6px 12px rgba(255, 255, 255, 0.8);
--shadow-neu-in:
inset 4px 4px 8px rgba(163, 177, 198, 0.6),
inset -4px -4px 8px rgba(255, 255, 255, 0.8);
--shadow-neu-display:
inset 6px 6px 12px rgba(163, 177, 198, 0.7),
inset -6px -6px 12px rgba(255, 255, 255, 0.9);
}
.neu-surface {
background-color: var(--color-neu-base);
box-shadow: var(--shadow-neu-out);
}
.neu-button {
background-color: var(--color-neu-base);
box-shadow: var(--shadow-neu-out);
transition: box-shadow 80ms ease, transform 80ms ease;
}
.neu-button:active,
.neu-button[data-pressed='true'] {
box-shadow: var(--shadow-neu-in);
transform: scale(0.97);
}
.neu-display {
background-color: var(--color-neu-base);
box-shadow: var(--shadow-neu-display);
}Building the Calculator Component in React
The component itself is straightforward. You need a display area, a grid of buttons, and a tiny state machine for arithmetic. Keep the logic separate from the presentation — it makes it far easier to swap out the UI style later. If you ever want to port this to a glassmorphism theme, you'd only need to swap the CSS classes.
// NeuCalculator.tsx
'use client';
import { useState, useCallback } from 'react';
const BUTTONS = [
'C', '+/-', '%', '÷',
'7', '8', '9', '×',
'4', '5', '6', '-',
'1', '2', '3', '+',
'0', '.', '=',
];
const OPS = new Set(['÷', '×', '-', '+']);
export default function NeuCalculator() {
const [display, setDisplay] = useState('0');
const [pending, setPending] = useState<number | null>(null);
const [op, setOp] = useState<string | null>(null);
const [justEvaled, setJustEvaled] = useState(false);
const handleKey = useCallback((key: string) => {
if (key === 'C') {
setDisplay('0'); setPending(null); setOp(null); setJustEvaled(false);
return;
}
if (key === '+/-') { setDisplay(d => String(parseFloat(d) * -1)); return; }
if (key === '%') { setDisplay(d => String(parseFloat(d) / 100)); return; }
if (OPS.has(key)) {
setPending(parseFloat(display));
setOp(key);
setJustEvaled(true);
return;
}
if (key === '=') {
if (pending === null || op === null) return;
const a = pending, b = parseFloat(display);
const result =
op === '+' ? a + b :
op === '-' ? a - b :
op === '×' ? a * b :
op === '÷' ? (b === 0 ? NaN : a / b) : b;
setDisplay(isNaN(result) ? 'Error' : String(parseFloat(result.toFixed(10))));
setPending(null); setOp(null); setJustEvaled(true);
return;
}
// digit or decimal
setDisplay(prev => {
if (justEvaled) { setJustEvaled(false); return key === '.' ? '0.' : key; }
if (key === '.' && prev.includes('.')) return prev;
return prev === '0' && key !== '.' ? key : prev + key;
});
}, [display, pending, op, justEvaled]);
return (
<div className="neu-surface rounded-3xl p-6 w-72 mx-auto select-none">
{/* Display */}
<div className="neu-display rounded-2xl p-4 mb-6 text-right">
<span className="text-3xl font-light tracking-wide text-slate-600 break-all">
{display}
</span>
</div>
{/* Button grid */}
<div className="grid grid-cols-4 gap-3">
{BUTTONS.map((key) => (
<button
key={key}
onClick={() => handleKey(key)}
className={[
'neu-button rounded-2xl h-14 text-lg font-medium',
key === '0' ? 'col-span-2' : '',
OPS.has(key) || key === '='
? 'text-indigo-500'
: 'text-slate-500',
].join(' ')}
>
{key}
</button>
))}
</div>
</div>
);
}A few decisions worth noting. The 80ms transition on box-shadow is fast enough to feel instant but slow enough for the eye to register the press. Go above 120ms and it starts feeling sluggish. The transform: scale(0.97) gives a micro-physical nudge that pairs naturally with the shadow inversion — you get two simultaneous visual cues of "pressed" instead of one.
Dark Mode Neumorphism: Swapping the Base Color
Light grey is the classic neumorphic surface — but dark neumorphism is increasingly popular and frankly looks more at home in developer-facing tools. The formula is identical, just mirrored in luminance. Your base shifts to something like #1e2130, your dark shadow becomes rgba(10,12,20,0.7), and your light shadow becomes rgba(50,58,80,0.5). The key insight: you still need a light shadow and a dark shadow, they're just both darker than you'd expect.
Pair this with a theme toggle in React and you get a calculator that switches modes smoothly. The trick is to keep your base color and shadow values as CSS custom properties — swapping data-theme on <html> then overrides the variables rather than repainting individual elements.
One thing to watch: contrast ratios drop quickly in dark neumorphism. The text on your display and the labels on your buttons must stay accessible. WCAG AA requires at least 4.5:1 for normal text. With a #1e2130 background, #8892b0 text gives you roughly 4.6:1 — barely passing. Go lighter if you want comfortable reading rather than just compliant numbers.
Accessibility Concerns You Actually Need to Fix
Here's the thing: neumorphism has a well-documented accessibility problem. The style's whole premise is low contrast — the button and the surface are the same color. That means keyboard focus indicators, disabled states, and hover effects all need extra work. You can't just slap outline: none and call it done.
Add an explicit focus ring using a color that has contrast against the base. Something like focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 in Tailwind does the job without breaking the aesthetic too much. The focus-visible pseudo-class means mouse users won't see the ring, only keyboard users — which is exactly the right behaviour.
Should you worry about users who need high contrast? Yes. Wrap the calculator in a prefers-contrast: more media query that swaps the shadow-only differentiation for an actual border. A 1px solid rgba(0,0,0,0.3) border doesn't ruin the neumorphic look entirely, and it gives motor-impaired users a much clearer target. What's the point of a beautiful UI if half your users can't operate it?
Neumorphism vs Other Soft UI Styles for Calculator UIs
You might wonder whether claymorphism or even glassmorphism would suit a calculator better. Claymorphism's thick outlines and inflated shapes make great icon sets and marketing heroes, but they scale badly into dense grid layouts — 16 clay-style buttons in a 4×4 grid becomes visual noise fast. Glassmorphism suffers a different problem: the frosted background requires a vivid image behind it, and calculators are usually standalone widgets over neutral backgrounds.
Neumorphism wins for calculator UIs specifically because it's self-contained. It doesn't depend on what's behind it. The background is the surface itself. That makes it trivially embeddable anywhere — dashboard sidebars, modal dialogs, settings panels. No external dependency on background content.
The style also ages well in isolation. Neobrutalism is having a moment right now, but it would look jarring as a calculator inside a polished SaaS product. Neumorphism is professional enough to sit next to data tables and charts without screaming for attention.
Shipping It: Performance and Bundle Size
The entire neumorphism calculator adds essentially zero JS weight beyond your React install. The CSS is a handful of custom properties and utility classes — Tailwind's purging will drop anything unused. In production, you're looking at maybe 2KB of additional CSS and whatever your component logic weighs, which for the snippet above is under 1KB minified.
The box-shadow property is GPU-composited in modern browsers, so animation and transition performance is excellent. You can safely run the 80ms press transition at 60fps even on mid-range mobile. The only thing to watch is if you're rendering dozens of calculators in a list (unlikely, but possible in a component showcase) — shadow painting on initial load can spike on older devices. A will-change: box-shadow hint helps, but only add it where you've actually measured a problem.
For anyone pulling components from Empire UI's style library, the neumorphism design tokens slot directly into the component system. You define your base color once, the shadow scale derives from it automatically, and every neumorphic component — not just this calculator — inherits the right aesthetic. That's the actual value of a design token system: consistency without repetition.
FAQ
The most common cause is a background color mismatch. Your button background must exactly match the parent surface color — if they differ by even a few percentage points in lightness, the illusion collapses. Also check that you're using two shadows (a light one offset top-left, a dark one offset bottom-right), not just one. A single shadow produces a regular drop shadow, not neumorphism.
Tailwind's built-in shadow utilities won't cut it here — they're single-layer, single-color shadows. You need to define custom shadow tokens. In Tailwind v4.0.2, use the @theme block in your CSS file to register --shadow-neu-out and --shadow-neu-in variables, then reference them via inline style props or custom utility classes. There's no way around hand-rolling these values.
Use a data-pressed attribute toggled via onPointerDown / onPointerUp handlers, or rely on the CSS :active pseudo-class for most cases. The :active approach works fine for click interactions but misses keyboard-triggered presses — for full coverage, track a isPressed boolean in state and set data-pressed={isPressed} on the button element, then style it with [data-pressed='true'] in your CSS.
Not by default, no. The design relies on low contrast between elements, which fails WCAG AA for users with low vision. You need to supplement the shadow differentiation with explicit focus rings using focus-visible, ensure text contrast ratios stay above 4.5:1, and consider a prefers-contrast: more fallback that adds visible borders. A beautiful calculator that's unusable for a chunk of your audience isn't actually a good component.
The classic is #e0e5ec — a warm light grey with a slight blue tint that produces clean shadow separation. Avoid pure white (#ffffff) because the light shadow becomes invisible. Avoid anything too saturated because it fights with the shadow colors. For dark mode, #1e2130 is a solid starting point. Whatever you pick, the key is that it sits luminance-wise between your light and dark shadow colors.
Check whether the current display string already includes a dot before appending one: if (key === '.' && prev.includes('.')) return prev;. The example component in this article handles this in the handleKey callback. Also reset the display string whenever the user starts fresh after an operator press — the justEvaled flag in the snippet covers this case.