EmpireUI
Get Pro
← Blog7 min read#neumorphism#icon-button#soft-ui

Neumorphism Icon Buttons: Soft UI Action Controls

Build neumorphism icon buttons with soft UI shadows, Tailwind v4, and accessible React. Real code, real values — no fluff, just working controls.

Soft neumorphic UI buttons with light gray background and inset shadow effects on a minimal interface

What Makes a Neumorphic Icon Button Different

Honestly, neumorphism is one of those styles that looks dead simple until you try to build it yourself — and then you spend 45 minutes wondering why your shadows look muddy instead of soft. Icon buttons are the sharpest test case for this style. They're small, they need to communicate state clearly, and the entire visual effect lives in two box-shadows.

A standard button has a background, a border, maybe a hover color. A neumorphic icon button has none of that in the traditional sense. The button appears to extrude from or press into the surface it sits on. The background matches the parent. The depth comes entirely from a light shadow on the top-left and a dark shadow on the bottom-right — or the inverse for a pressed state.

If you've read what is neumorphism already, you know the concept. But knowing the concept and building a production-ready interactive button component are two different things. This article is about the second one.

The reason icon buttons are the ideal neumorphism component: there's no text to worry about, the hit target can be a clean square or circle, and the state transitions (default → hover → active) map perfectly onto the three depth modes the style supports.

The Shadow Math Behind Soft UI Buttons

Everything in neumorphism depends on one number: your base background color. Let's say it's #e0e5ec. Your light shadow needs to be a lighter tint — around #ffffff or rgba(255,255,255,0.8). Your dark shadow needs to be a darker shade — something like #a3b1c6. The offset, blur, and spread are what control how 'deep' the button feels.

A typical resting state for a 48px icon button looks like this: box-shadow: 6px 6px 12px #a3b1c6, -6px -6px 12px #ffffff. The 6px offset gives you visible depth without looking cartoonish. Going to 10px or 12px offset starts reading as a drop shadow rather than extrusion — avoid it for small buttons.

The pressed state flips to an inset shadow: box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #ffffff. Note the smaller values. Inset shadows on small elements need tighter numbers or the effect bleeds into the icon itself and kills legibility.

Why does the offset asymmetry matter? The human eye reads upper-left as the light source by convention. Matching that convention is what makes the button feel physically real rather than just styled. Flip the shadows and the button reads as 'wrong' even if users can't articulate why.

Building the React Component with Tailwind v4

Tailwind v4.0.2 doesn't ship utility classes for arbitrary box-shadow values out of the box in a way that covers the dual-shadow pattern. You'll use the shadow-[...] arbitrary value syntax, or — honestly the better move — define your custom shadows in your CSS config and use semantic class names. Here's the full component:

// NeumorphicIconButton.tsx
import { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';

interface NeumorphicIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  icon: ReactNode;
  label: string; // required for aria-label
  size?: 'sm' | 'md' | 'lg';
  variant?: 'raised' | 'flat' | 'inset';
  active?: boolean;
}

const sizeMap = {
  sm: 'w-9 h-9',
  md: 'w-12 h-12',
  lg: 'w-16 h-16',
};

export function NeumorphicIconButton({
  icon,
  label,
  size = 'md',
  variant = 'raised',
  active = false,
  className,
  ...props
}: NeumorphicIconButtonProps) {
  return (
    <button
      aria-label={label}
      aria-pressed={variant === 'inset' || active ? true : undefined}
      className={cn(
        'inline-flex items-center justify-center rounded-2xl',
        'bg-[#e0e5ec] text-slate-500 transition-all duration-150',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
        sizeMap[size],
        variant === 'raised' && !active &&
          'shadow-[6px_6px_12px_#a3b1c6,-6px_-6px_12px_#ffffff] hover:shadow-[4px_4px_8px_#a3b1c6,-4px_-4px_8px_#ffffff] active:shadow-[inset_4px_4px_8px_#a3b1c6,inset_-4px_-4px_8px_#ffffff]',
        (variant === 'inset' || active) &&
          'shadow-[inset_4px_4px_8px_#a3b1c6,inset_-4px_-4px_8px_#ffffff] text-slate-700',
        variant === 'flat' &&
          'shadow-[2px_2px_4px_#a3b1c6,-2px_-2px_4px_#ffffff]',
        className
      )}
      {...props}
    >
      <span className="pointer-events-none">{icon}</span>
    </button>
  );
}

A few things worth noting in that code. The aria-label is not optional — icon-only buttons without accessible labels are an accessibility failure, full stop. The aria-pressed attribute only gets set when the button is in a toggle context, which is why it's conditional. And pointer-events-none on the icon span prevents the icon element from being the event target on click, which avoids some subtle React synthetic event oddities.

CSS Custom Properties for Theme Flexibility

Hardcoding #e0e5ec, #a3b1c6, and #ffffff into your component works fine for a fixed light theme. But what if you're building something that supports theme switching? You'll want to pull those values into CSS custom properties and let your theme layer control them. This also makes dark-mode neumorphism possible — and dark neumorphism is genuinely interesting.

/* globals.css or your design token layer */
:root {
  --neu-bg: #e0e5ec;
  --neu-shadow-dark: #a3b1c6;
  --neu-shadow-light: rgba(255, 255, 255, 0.9);
  --neu-radius: 1rem;
  --neu-depth: 6px;
  --neu-blur: 12px;
}

[data-theme="dark"] {
  --neu-bg: #1e2130;
  --neu-shadow-dark: #14161f;
  --neu-shadow-light: rgba(255, 255, 255, 0.05);
}

.neu-btn {
  background: var(--neu-bg);
  border-radius: var(--neu-radius);
  box-shadow:
    var(--neu-depth) var(--neu-depth) var(--neu-blur) var(--neu-shadow-dark),
    calc(var(--neu-depth) * -1) calc(var(--neu-depth) * -1) var(--neu-blur) var(--neu-shadow-light);
  transition: box-shadow 150ms ease, transform 80ms ease;
}

.neu-btn:active {
  box-shadow:
    inset var(--neu-depth) var(--neu-depth) var(--neu-blur) var(--neu-shadow-dark),
    inset calc(var(--neu-depth) * -1) calc(var(--neu-depth) * -1) var(--neu-blur) var(--neu-shadow-light);
}

If you're already using a theme toggle in React that switches a data-theme attribute on the root element, this CSS approach slots right in. You swap the Tailwind arbitrary values for the .neu-btn class and let the custom properties do the heavy lifting. The Tailwind classes handle layout and spacing; the CSS handles the visual depth system.

Icon Button States: Default, Hover, Active, Disabled

Four states to cover — and each one communicates something different to the user. Default is the button sitting at rest, extruding from the surface. Hover should reduce the shadow depth slightly (smaller offsets, maybe 4px instead of 6px) to hint that the button is reachable. Active — the moment the user is pressing — flips to inset. Disabled needs to flatten out entirely.

The disabled state is where a lot of neumorphic UIs fall down. They just reduce opacity, which leaves the depth effect intact on a button that doesn't respond. That's confusing. Better to reduce the shadow to nearly nothing: shadow-[1px_1px_2px_#c8cfd8,-1px_-1px_2px_#f8f9fb] plus opacity-50 cursor-not-allowed. The button reads as part of the surface rather than above it.

Is there a right answer for hover feedback? Not universally — but the most natural-feeling approach is to add a very subtle transform: translateY(-1px) on hover alongside the reduced shadow. It amplifies the 'this is clickable' signal without breaking the neumorphic illusion. Keep the transform small. More than 2px and it starts to feel like a different design system entirely.

For toggle buttons — like a mute button that stays active — the active prop on the component above handles the persistent inset state. Combine this with a color change on the icon itself (text-indigo-500 when active, text-slate-500 when not) and the state is unmistakable.

Neumorphism vs Glassmorphism for Icon Buttons

Both styles are in heavy rotation right now, and they're often misunderstood as interchangeable. They're not. If you want the full picture, the glassmorphism vs neumorphism breakdown covers the philosophy. For icon buttons specifically, the choice comes down to your background.

Glassmorphism icon buttons work best over images, gradients, or complex backgrounds — the frosted glass effect needs something behind it to show through. Neumorphic buttons demand a solid, uniform background. They break instantly if you put them over a gradient or image. This isn't a flaw; it's a constraint you design around.

From a build complexity standpoint, neumorphism is simpler: two box-shadows, one background color, done. Best free glassmorphism components often need backdrop-filter, background: rgba(255,255,255,0.15), border tricks, and careful z-index stacking. For a dashboard with a solid gray or off-white background, neumorphism is less code and more cohesive.

Accessibility Considerations You Can't Skip

Neumorphism has a documented accessibility problem: low contrast. The style relies on subtle shadow differences on a uniform background, which can be invisible to users with low vision or in bright ambient light conditions. The WCAG 2.1 AA threshold for UI components is a 3:1 contrast ratio against adjacent colors — and a lot of 'nice looking' neumorphic palettes fail this test.

What do you do about it? First, test your shadow colors with a contrast checker — not just your text. Tools like the Colour Contrast Analyser from TPGi let you check the contrast between your button surface and the background it sits on. Second, always include an icon that carries semantic meaning on its own. A button that's only distinguishable by its shadow depth will be invisible to screen readers and low-vision users if the aria-label is missing.

Focus rings are the other common failure point. The default browser focus ring gets visually lost on neumorphic surfaces because there's no clear border for it to appear against. The focus-visible:ring-2 focus-visible:ring-slate-400 in the component above is a minimum — for a production app, bump it to ring-offset-2 and pick a ring color with stronger contrast against #e0e5ec.

And yes, the cursor: pointer matters. It's a trivial thing, but on an icon button with no text and no obvious border, users need every affordance they can get. Don't remove it in the name of 'cleaner code'.

Organizing an Icon Button Toolbar with 8px Gaps

A single neumorphic icon button is fine. A toolbar of them is where the style really sings — and where spacing decisions become critical. The standard recommendation is an 8px gap between buttons in a horizontal toolbar. Less than 8px and the shadows start to bleed into each other, creating a muddy merged effect. More than 16px and the buttons stop reading as a cohesive group.

Use flex with gap-2 (which is 8px in Tailwind's default scale) as your starting point. The container itself should have the same --neu-bg background as the buttons — if the parent is a different shade, the buttons will look like they're floating on a different surface, which breaks the illusion immediately.

Grouping actions visually? A subtle divider works better than trying to create a 'group' container with its own neumorphic effect. A 1px vertical line at rgba(0,0,0,0.1) between groups is enough. Keep the buttons themselves visually consistent — don't mix raised and flat variants in the same toolbar unless you're explicitly communicating hierarchy (like a primary action vs secondary actions).

If you're working with a broader component library and want to see how this pattern sits alongside other particles background effects in React, remember that neumorphism and animated backgrounds are a bad combination. The shadow-based depth system assumes a static surface. Let the buttons breathe on their own.

FAQ

Why do my neumorphic shadows look muddy instead of soft?

Almost always a color issue. Your shadow colors need to be derived from the base background — a lighter tint for the highlight and a darker shade for the depth shadow. Using pure black and pure white as shadows on a mid-gray background creates harsh, non-neumorphic results. Start with your base color (e.g. #e0e5ec) and adjust by around 15-20% lightness in each direction.

Can neumorphic icon buttons work in dark mode?

Yes, but the shadow values change significantly. In dark mode your base is a dark color like #1e2130. Your dark shadow becomes nearly black (#14161f), and your light shadow becomes a very subtle rgba(255,255,255,0.05) — not white. Using the same shadow values you use in light mode on a dark background will just look broken.

What border-radius should I use for neumorphic icon buttons?

It depends on your overall design language. Fully circular (border-radius: 50%) is the most common for icon buttons — it matches the round icon aesthetic. A 16px or 1rem radius (rounded-2xl in Tailwind) gives you a 'squircle' feel that reads as slightly more serious. Avoid sharp corners entirely — the shadow effect reads as unnatural on right angles.

How do I handle the pressed/active state on mobile where there's no hover?

On touch devices, the :active pseudo-class fires on tap — so your active state CSS still triggers. The issue is that :hover doesn't exist, meaning mobile users go straight from default to active without the hover hint. This is fine — the active inset shadow is a strong enough affordance on its own. Just make sure your transition duration on the shadow is 150ms or less so it feels responsive on tap.

Is there a performance cost to animating box-shadow on these buttons?

Yes, box-shadow animation triggers paint (not just composite) in the browser, so it's more expensive than animating transform or opacity. For a toolbar of 5-6 buttons this is imperceptible. For a page with hundreds of animated neumorphic buttons, you'd want to profile it. One mitigation: animate the opacity of a ::before pseudo-element that holds the inset shadow, while keeping the raised shadow static — this moves more of the work to the compositor.

What's the minimum accessible size for a neumorphic icon button?

WCAG 2.5.5 (Level AAA) recommends 44x44px minimum touch targets. The more common Level AA requirement from WCAG 2.5.8 targets 24x24px for the component itself with adequate spacing. For neumorphic buttons specifically, go with at least 40x40px — smaller than that and the shadow offsets needed to show depth start eating into the icon's legible area.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Neumorphism Calculator: Soft UI Mathematical InterfaceNeumorphism Radio Buttons: Soft UI Selection ComponentsReact UI Components Complete Reference: 60+ Patterns with CodeProgress Stepper Component in React: Wizard UI with State