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

Neumorphism Radio Buttons: Soft UI Selection Components

Build accessible neumorphic radio buttons with soft shadows and Tailwind v4. Real code, real CSS values, no fluff — just tactile UI that actually works.

Soft neumorphic UI components with embossed and debossed surface effects on a light grey background

Why Neumorphic Radio Buttons Are Worth the Trouble

Honestly, neumorphic radio buttons are one of the few UI patterns where the visual payoff actually justifies the CSS complexity. Most form controls look like they were designed by someone who hates tactile feedback. Neumorphism fixes that.

The core idea is simple: every element looks extruded from the background surface, not drawn on top of it. For a radio button, that means the unselected state sits slightly raised, and clicking it pushes it inward — a debossed dot that screams 'selected' without a single pixel of border color change.

If you've already read what is neumorphism, you know the style lives and dies by two shadow values: a light shadow on the top-left, a dark shadow on the bottom-right. Radio buttons are the perfect canvas to apply that principle to interactive state changes.

The Shadow Math Behind Soft UI Radio Buttons

Before touching any React, you need to understand the shadow pair. Every neumorphic surface uses two box-shadows. One light (rgba(255,255,255,0.75)) offset to the upper-left, one dark (rgba(0,0,0,0.15)) offset to the lower-right. The background color of the element must match the background color of its parent — exactly. That's not optional.

For a 20×20px radio button sitting on a #e0e5ec background, a starting shadow pair looks like: box-shadow: -3px -3px 6px rgba(255,255,255,0.8), 3px 3px 6px rgba(0,0,0,0.15). When selected, you invert it: box-shadow: inset -3px -3px 6px rgba(255,255,255,0.8), inset 3px 3px 6px rgba(0,0,0,0.15). The inset keyword does all the heavy lifting for the pressed state.

The blur radius matters more than most developers realize. Push it too high — say, 12px on a 20px element — and the shadow bleeds into adjacent elements and destroys the illusion. Stay in the 4px–8px range for small controls. You can go up to 20px for cards or panels, but for radio buttons, restrain yourself.

Building the React Component: Structure First

Let's build this from scratch. No third-party wrapper, no CSS-in-JS runtime — just a clean TSX component that accepts standard radio input props and adds the neumorphic skin on top.

The key structural decision is hiding the native <input type="radio"> and replacing it visually with a styled <span>. This keeps accessibility intact because screen readers still find the actual input, but we get full control over the visual surface.

import React, { forwardRef } from 'react';

interface NeumorphicRadioProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  description?: string;
}

export const NeumorphicRadio = forwardRef<HTMLInputElement, NeumorphicRadioProps>(
  ({ label, description, className, ...props }, ref) => {
    return (
      <label className="flex items-center gap-3 cursor-pointer group">
        <span className="relative flex-shrink-0">
          <input
            type="radio"
            ref={ref}
            className="sr-only peer"
            {...props}
          />
          {/* Outer ring — raised by default */}
          <span
            className="
              block w-5 h-5 rounded-full
              bg-[#e0e5ec]
              shadow-[−3px_-3px_6px_rgba(255,255,255,0.8),_3px_3px_6px_rgba(0,0,0,0.15)]
              peer-checked:shadow-[inset_-3px_-3px_6px_rgba(255,255,255,0.8),_inset_3px_3px_6px_rgba(0,0,0,0.15)]
              peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-blue-500
              transition-shadow duration-200
            "
          />
          {/* Inner dot — only visible when checked */}
          <span
            className="
              absolute inset-0 flex items-center justify-center
              opacity-0 peer-checked:opacity-100
              transition-opacity duration-200
            "
          >
            <span className="w-2 h-2 rounded-full bg-blue-500" />
          </span>
        </span>
        <span className="select-none">
          <span className="block text-sm font-medium text-gray-700">{label}</span>
          {description && (
            <span className="block text-xs text-gray-500 mt-0.5">{description}</span>
          )}
        </span>
      </label>
    );
  }
);

NeumorphicRadio.displayName = 'NeumorphicRadio';

Tailwind v4 Shadow Utilities vs Custom CSS Values

Here's the awkward truth about Tailwind and neumorphism: Tailwind's built-in shadow scale wasn't designed for this. shadow-md gives you one shadow. Neumorphism needs two, with precise color control. Tailwind v4.0.2 supports arbitrary values in shadow-[] which gets you most of the way there, but the syntax gets hairy fast.

For a production component, you're better off defining a custom utility in your tailwind.config.ts. Add a neu-raised and neu-pressed pair under theme.extend.boxShadow. That keeps your JSX readable and your values consistent across the whole design system.

// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  theme: {
    extend: {
      boxShadow: {
        'neu-raised':
          '-4px -4px 8px rgba(255,255,255,0.8), 4px 4px 8px rgba(0,0,0,0.15)',
        'neu-pressed':
          'inset -3px -3px 6px rgba(255,255,255,0.8), inset 3px 3px 6px rgba(0,0,0,0.15)',
        'neu-raised-sm':
          '-2px -2px 4px rgba(255,255,255,0.8), 2px 2px 4px rgba(0,0,0,0.12)',
        'neu-pressed-sm':
          'inset -2px -2px 4px rgba(255,255,255,0.8), inset 2px 2px 4px rgba(0,0,0,0.12)',
      },
    },
  },
} satisfies Config;

With those utilities in place, your component class list shrinks from a wall of arbitrary values to shadow-neu-raised peer-checked:shadow-neu-pressed. Much more maintainable when you're revisiting this six months later.

Dark Mode: The Neumorphism Problem Nobody Talks About

Neumorphism has a well-documented dark mode problem. The style depends on a mid-tone background — something around L50 in HSL space. Go too dark and you can't see the light shadow. Go too light and the dark shadow disappears. Pure black (#000000) breaks the illusion completely.

For dark neumorphism, you need a background in the #1e2130 to #2d3250 range. The light shadow shifts from rgba(255,255,255,0.8) to something much dimmer, like rgba(255,255,255,0.05). The dark shadow deepens to rgba(0,0,0,0.5). And the whole palette needs re-tuning.

The comparison in glassmorphism vs neumorphism is worth reading here — glassmorphism actually handles dark mode more gracefully because its blur effects don't rely on mid-tone backgrounds. Neumorphism is fundamentally a light-palette style. You can do dark mode, but it requires two separate shadow token sets, not just a color inversion.

If you're using Empire UI's theme toggle pattern, wire up a data-theme attribute on <html> and scope your neumorphic shadow tokens to it. Something like [data-theme='dark'] .neu-raised { box-shadow: -4px -4px 8px rgba(255,255,255,0.04), 4px 4px 8px rgba(0,0,0,0.5); }. Don't try to do this with Tailwind's dark: variant alone — you'll end up duplicating arbitrary values everywhere.

Accessibility Audit: What You Must Not Skip

Radio buttons are interactive form controls. They have WCAG requirements. Neumorphic styling makes this harder because the style deliberately avoids high-contrast borders — which is exactly what WCAG 1.4.11 (Non-text Contrast, ratio 3:1) requires for UI components.

Do not rely on shadow depth alone to communicate state. The pressed inset shadow that you and I can see clearly fails for users with low vision or on washed-out displays. You need a secondary indicator: the inner dot (already in our component above), a color shift, or both. The blue dot at w-2 h-2 in our example pulls double duty — it's a color cue and a shape cue.

Always keep the sr-only native input in the DOM. Screen readers need it. VoiceOver on macOS and NVDA on Windows both read <input type="radio"> natively — your custom <span> is invisible to them. The peer-focus-visible ring we added ensures keyboard users see a clear focus indicator without ruining the visual style for mouse users. WCAG 2.4.11 compliance without compromise.

What happens if you're in a radio group and someone hits the arrow key? The browser handles that natively through the name attribute on each <input>. Keep the name prop consistent across your group and you get arrow-key navigation for free. Don't reinvent it with onKeyDown handlers.

Radio Group Pattern: Composing Multiple Options

A single radio button isn't useful in isolation. You need a group. The cleanest pattern in React is a controlled RadioGroup wrapper that manages the value state and passes checked and onChange down to each NeumorphicRadio child.

import { useState } from 'react';
import { NeumorphicRadio } from './NeumorphicRadio';

const plans = [
  { value: 'starter', label: 'Starter', description: 'Up to 3 projects' },
  { value: 'pro', label: 'Pro', description: 'Unlimited projects' },
  { value: 'enterprise', label: 'Enterprise', description: 'Custom limits' },
];

export function PricingRadioGroup() {
  const [selected, setSelected] = useState('pro');

  return (
    <fieldset className="space-y-3 p-6 rounded-2xl bg-[#e0e5ec]">
      <legend className="text-sm font-semibold text-gray-600 mb-4">Select plan</legend>
      {plans.map((plan) => (
        <NeumorphicRadio
          key={plan.value}
          name="pricing-plan"
          value={plan.value}
          label={plan.label}
          description={plan.description}
          checked={selected === plan.value}
          onChange={() => setSelected(plan.value)}
        />
      ))}
    </fieldset>
  );
}

Notice the <fieldset> and <legend> — those aren't optional. They're what groups the radio buttons semantically for screen readers. The fieldset has rounded-2xl and the matching bg-[#e0e5ec] background so the neumorphic shadows on each radio don't fight with a mismatched parent color. Everything sits on the same surface.

Performance and Animation Considerations

Neumorphic transitions are cheap if you do them right. transition-shadow duration-200 on the radio button triggers a compositor-layer paint — the browser handles it without touching layout. That's the good news. The bad news: if you're animating box-shadow on hundreds of elements simultaneously, you'll see jank on lower-end devices.

For radio buttons specifically, the animation surface area is small. One element changes state at a time. So transition-shadow is perfectly fine here. Where you need to be careful is if you're applying neumorphic effects inside a virtualized list or a table with 500+ rows. In those cases, skip the transition and apply the state class directly.

Also worth noting: will-change: box-shadow is not your friend here. It promotes the element to its own GPU layer, which costs memory. For tiny 20px controls, the promotion overhead exceeds any rendering gain. Leave will-change off unless you're profiling and seeing an actual problem. The style comparison in what is claymorphism touches on similar performance tradeoffs for decorative UI styles — the same logic applies.

FAQ

Why do my neumorphic shadows look flat or invisible?

Almost always a background mismatch. The element's background-color must be identical to the parent container's background. If your parent is #e0e5ec and your radio button is #ffffff, the shadows lose their grounding effect. Match them exactly — including in dark mode.

Can I use neumorphic radio buttons with React Hook Form or Zod?

Yes, no issues there. The component uses forwardRef and spreads native input props, so register() from React Hook Form works exactly as it would on a native input. Just pass the ref and the rest of the spread: <NeumorphicRadio {...register('plan')} value="pro" label="Pro" />.

How do I handle the WCAG non-text contrast requirement with neumorphism?

You need a secondary indicator beyond shadow depth alone. The inner colored dot in the selected state is the most straightforward solution — it provides both a color cue and a shape cue. Run your component through axe-core or the Chrome Accessibility panel to verify the 3:1 contrast ratio on interactive state indicators.

What background color range works best for neumorphism?

Aim for mid-tone neutrals in the HSL L45–L60 range. Hex values like #e0e5ec, #dde1e7, and #d4d8df work well. Pure white (#ffffff) and pure black (#000000) both break the illusion. For dark mode, try #1e2130 to #2d3250 with reduced white shadow opacity around rgba(255,255,255,0.05).

Should I use Tailwind arbitrary shadow values or custom utilities?

Custom utilities every time for production work. Arbitrary values like shadow-[-3px_-3px_6px_rgba(255,255,255,0.8),_3px_3px_6px_rgba(0,0,0,0.15)] are hard to read and impossible to keep consistent across components. Define neu-raised and neu-pressed in tailwind.config.ts under theme.extend.boxShadow and reference them by name.

Does neumorphic styling affect radio button keyboard navigation?

Not if you keep the native input in the DOM with sr-only. The browser handles all keyboard behavior — Tab to focus the group, arrow keys to move between options — through the native input. Your custom visual wrapper is purely decorative. Don't add keyboard event handlers unless you have a very specific accessibility requirement.

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

Read next

Neumorphism Icon Buttons: Soft UI Action ControlsNeumorphism Volume Control: Media Slider with Soft UIDrag-and-Drop Sortable Lists in React: No Library RequiredImage Gallery with Lightbox: Accessible Photo Viewer in React