Soft UI Buttons: 12 Neumorphic Variants with Hover States
12 neumorphic button variants built with Tailwind v4 and React — raised, inset, flat, and animated hover states that actually hold up in production UIs.
What Soft UI Actually Means for Buttons
Honestly, neumorphism is the one design trend that developers either love immediately or dismiss as impractical — and both reactions make sense. The style mimics physical extrusion: UI elements appear to push out of or sink into the background surface. Buttons are where this becomes most expressive.
The illusion relies on two shadows: one light, one dark, applied to opposite corners. Light source is assumed to come from the top-left. So you get a bright shadow at top-left and a dark shadow at bottom-right. That's it. Everything else is refinement.
The tricky part is that soft UI only works when the background and the element share the same base color. If you're building on #e0e5ec, your button needs to match that exact value. Deviate by even 10% and the effect collapses. This is why neumorphic components are notoriously difficult to theme without careful planning — something we tackled directly in Empire UI's soft-ui token system.
The Shadow Formula Behind All 12 Variants
Every variant in this set derives from a single base formula. You need two shadow values: a highlight and a shadow. For a #e0e5ec surface, that works out to #ffffff for the highlight and #a3b1c6 for the shadow. Most tutorials stop there. We don't.
The blur radius and offset matter a lot. At 6px offset with 12px blur you get a subtle, shallow press. At 10px offset with 20px blur you get a pronounced physical button that looks almost tactile. The 12 variants here range from box-shadow: 6px 6px 12px #a3b1c6, -6px -6px 12px #ffffff (flat raised) all the way to an inset pressed state using inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #ffffff.
One thing worth noting: Tailwind v4.0.2's arbitrary value syntax handles all of this cleanly with shadow-[6px_6px_12px_#a3b1c6,-6px_-6px_12px_#ffffff]. If you're on v3, the underscore escaping works differently and you'll need a theme.extend.boxShadow entry in your config instead.
All 12 Variants: From Raised to Ghost
Here's a breakdown of the 12 button types this article covers. Raised (default), Raised Active, Inset, Inset Active, Flat, Flat Hover, Concave, Convex, Outline Soft, Ghost Soft, Icon Soft, and Toggle Soft. Each has a distinct shadow configuration and hover transition.
The Concave and Convex variants are the most interesting. Concave buttons use a gradient from the dark tone to the light tone across the button face — giving a curved-inward look. Convex does the reverse. Neither of these is achievable with box-shadow alone; they need a background gradient layered on top of the base color.
Toggle Soft deserves a mention here. It's a stateful button that flips between raised and inset on click, holding the inset state while active. This one's particularly useful for sidebar toggle controls, theme switches, and on/off indicators. If you're building something like a theme toggle in React, this variant maps naturally onto that interaction pattern.
Code: The Base Neumorphic Button in React + Tailwind
Let's write this out. The component below gives you the raised and inset states with a smooth 150ms transition. It's written for Tailwind v4.0.2 using arbitrary shadow values.
type SoftButtonProps = {
label: string;
variant?: 'raised' | 'inset' | 'flat';
onClick?: () => void;
};
const shadowMap = {
raised: '6px 6px 12px #a3b1c6, -6px -6px 12px #ffffff',
inset: 'inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #ffffff',
flat: '2px 2px 5px #a3b1c6, -2px -2px 5px #ffffff',
};
export function SoftButton({
label,
variant = 'raised',
onClick,
}: SoftButtonProps) {
return (
<button
onClick={onClick}
style={{ boxShadow: shadowMap[variant] }}
className="
bg-[#e0e5ec] text-[#5a6a85]
px-6 py-3 rounded-xl
font-medium text-sm
transition-all duration-150
hover:brightness-[1.02]
active:shadow-[inset_4px_4px_8px_#a3b1c6,inset_-4px_-4px_8px_#ffffff]
"
>
{label}
</button>
);
}The active: pseudo-class does the heavy lifting for the press feedback. You don't need JavaScript state for this — the CSS handles it. For the Toggle Soft variant you will need state, but for most cases keeping it CSS-only is better for performance and reduces re-renders.
Hover State Design: What Actually Feels Right
This is where a lot of neumorphic implementations fall apart. The hover state needs to signal interactivity without breaking the physical metaphor. You can't just change color — that kills the effect. What works: slightly increasing the shadow spread, brightening the surface by 1-2%, or transitioning to a midpoint between raised and inset.
The 12px-to-14px shadow spread expansion on hover is subtle but effective. It reads as the button "rising" slightly toward the user before they press it. Pair that with a cursor-pointer and a 150ms ease transition and you've got something that feels genuinely tactile.
What doesn't work: hover:scale-105. Scaling a neumorphic button breaks the surface illusion immediately because the shadow proportions stay fixed while the element grows. If you want scale feedback, you need to scale the shadow values in sync — which isn't trivial in CSS alone. Stick to shadow and brightness changes. Is it less flashy? Yes. Does it feel more satisfying to click? Absolutely.
Dark Mode: The Problem Nobody Warns You About
Neumorphism and dark mode don't play nice by default. The light-source illusion requires light shadows to be visibly lighter than the background. On a dark surface like #1e2a3a, your "light" shadow at rgba(255,255,255,0.15) and your "dark" shadow at rgba(0,0,0,0.4) need careful calibration or the depth effect disappears entirely.
The fix is using CSS custom properties and updating them per theme. Set --neu-light: rgba(255,255,255,0.08) and --neu-dark: rgba(0,0,0,0.5) for dark mode, versus --neu-light: #ffffff and --neu-dark: #a3b1c6 for light mode. Then your shadow values reference these variables instead of hardcoded hex values.
This is also why neumorphism pairs oddly with glassmorphism — they make opposing assumptions about the surface. Glassmorphism assumes a transparent, blurred surface while neumorphism assumes an opaque, solid one. You can combine them in layered UIs but probably not on the same element. For a direct comparison of how these approaches differ, glassmorphism vs neumorphism covers the tradeoffs in detail.
Accessibility Considerations for Soft UI Buttons
The biggest accessibility concern with neumorphic buttons is contrast. The text color sitting on #e0e5ec at #5a6a85 gives you roughly 4.1:1 contrast — barely passing WCAG AA for normal text. That's not a lot of margin. If you're building for compliance, bump the text to #3d4f66 to get above 5:1.
Focus states are the other issue. The default browser outline often clashes badly with neumorphic surfaces. You'll want a custom focus ring — something like focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b8cba] focus-visible:ring-offset-2 focus-visible:ring-offset-[#e0e5ec]. The ring-offset color needs to match your background exactly, same reason the shadow does.
Don't skip the aria-pressed attribute on toggle variants. Screen readers need to know the button state. A soft UI toggle that visually flips between raised and inset is meaningless to a user who can't see that change without the proper ARIA attribute wired up.
Using These Variants in Empire UI
Empire UI ships all 12 of these variants as a single <SoftButton> component with a variant prop. The component also supports an icon prop for the Icon Soft variant and a pressed prop for the Toggle Soft state. No extra configuration needed — it works out of the box on any Tailwind v4 project.
If you want to understand how neumorphism fits into the broader landscape of UI styles that Empire UI covers, the what is neumorphism guide is worth reading. And if you're trying to decide between soft UI and something with more visual contrast — say, neo-brutalism — what is neobrutalism lays out that decision pretty clearly.
One practical tip: don't mix neumorphic buttons with standard Tailwind shadow utilities on the same page. The visual language is too different and the page ends up feeling inconsistent. Commit to the style in a given section or component context, then use a clear boundary before switching to a different style system.
FAQ
Your shadow colors are probably hardcoded to light-mode values. In dark mode you need much lower-opacity white highlights — try rgba(255,255,255,0.08) for the light shadow and rgba(0,0,0,0.5) for the dark shadow. Use CSS custom properties so you can swap them per theme.
Not directly. Tailwind's shadow-* utilities only produce single-direction shadows. Neumorphism needs two shadows in opposite directions. Use arbitrary values: shadow-[6px_6px_12px_#a3b1c6,-6px_-6px_12px_#ffffff] in Tailwind v4, or extend theme.boxShadow in your config for v3.
Mid-range grays in the #d0d0d0 to #e8e8e8 range work best. #e0e5ec is a popular choice. The button background must match the page background exactly — any mismatch breaks the extrusion illusion. Avoid pure white or very dark backgrounds.
Use the CSS active: pseudo-class. In Tailwind: active:shadow-[inset_4px_4px_8px_#a3b1c6,inset_-4px_-4px_8px_#ffffff]. This switches from an outset to an inset shadow on mousedown without any state management. For persistent toggle states, you'll need aria-pressed and JavaScript.
It can, but you have to be deliberate. Text on #e0e5ec at color #5a6a85 sits at around 4.1:1 contrast — passing AA for normal text but with little headroom. Use #3d4f66 or darker for comfortable compliance. Always add a visible focus-visible ring since the shadow alone doesn't communicate focus.
Technically yes, but the two styles make opposite surface assumptions — glassmorphism is transparent and blurred, neumorphism is solid and opaque. Mixing them on the same element doesn't work. In separate, clearly bounded sections of a page it can coexist, but it requires intentional layout to avoid visual confusion.