EmpireUI
Get Pro
← Blog7 min read#neumorphism#podcast-player#audio-ui

Neumorphism Podcast Player: Audio UI with Soft Controls

Build a neumorphic podcast player UI in React and Tailwind with soft shadows, extruded controls, and accessible audio components that actually look great.

Close-up of podcast headphones and a minimalist audio interface on a light gray surface

Why Neumorphism Works So Well for Audio Controls

Honestly, neumorphism is one of those styles that divides rooms — but for audio player UI, it's genuinely the right call. The raised, extruded look maps perfectly to physical knobs, buttons, and sliders. When a user sees a neumorphic play button, their brain already knows what it's supposed to do. That tactile intuition is free.

The design language, if you haven't read the full neumorphism breakdown, works by offsetting two shadows — one light, one dark — from the same surface color. The result is an element that appears to pop out of or sink into the background. For a podcast player with play/pause, scrubbing, and volume controls, that's exactly the vocabulary you want.

It's worth noting this isn't the same as glassmorphism, which leans on transparency and blur. Neumorphism is fully opaque, which actually helps with audio UI contrast on light-gray backgrounds like #e0e5ec. Less visual noise, more focus on the controls.

Setting Up the Neumorphic Base in Tailwind v4.0.2

Before you wire up any audio state, get the visual foundation right. With Tailwind v4.0.2, you can use the shadow utilities directly in your JSX, but neumorphism's dual-shadow system requires arbitrary values. You'll define two custom shadow directions and compose them.

Add these to your tailwind.config.ts under theme.extend.boxShadow. The values below target a #e0e5ec background, which is the sweet spot for soft-UI work:

// tailwind.config.ts
module.exports = {
  theme: {
    extend: {
      boxShadow: {
        'neu-flat': '6px 6px 12px #b8bec7, -6px -6px 12px #ffffff',
        'neu-inset': 'inset 4px 4px 8px #b8bec7, inset -4px -4px 8px #ffffff',
        'neu-pressed':
          'inset 6px 6px 12px #b8bec7, inset -6px -6px 12px #ffffff',
      },
      colors: {
        neu: '#e0e5ec',
      },
    },
  },
};

The neu-flat shadow gives you extruded elements. neu-pressed is for active states — when the user holds down the play button, swap the class and it instantly reads as pressed. That one CSS property swap does all the interaction feedback work.

Building the Podcast Player Shell Component

Start with the outer card. It needs the base background, rounded corners, and the flat shadow. Keep it generous on padding — p-8 minimum — so the shadows have room to breathe without clipping.

// components/PodcastPlayer.tsx
import { useState, useRef } from 'react';

export function PodcastPlayer({ src, title, host }: {
  src: string;
  title: string;
  host: string;
}) {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [playing, setPlaying] = useState(false);
  const [progress, setProgress] = useState(0);

  const toggle = () => {
    if (!audioRef.current) return;
    playing ? audioRef.current.pause() : audioRef.current.play();
    setPlaying(!playing);
  };

  const onTimeUpdate = () => {
    if (!audioRef.current) return;
    const pct =
      (audioRef.current.currentTime / audioRef.current.duration) * 100;
    setProgress(pct || 0);
  };

  return (
    <div className="bg-neu rounded-3xl shadow-neu-flat p-8 w-[380px] select-none">
      <audio ref={audioRef} src={src} onTimeUpdate={onTimeUpdate} />
      <p className="text-xs uppercase tracking-widest text-gray-400 mb-1">{host}</p>
      <h2 className="text-lg font-semibold text-gray-700 mb-6 leading-snug">
        {title}
      </h2>
      {/* controls go here */}
    </div>
  );
}

Notice the select-none on the wrapper — that stops text from getting highlighted when users drag on the scrubber. Small thing, but it matters a lot for touch interactions.

The Neumorphic Play/Pause Button with Active State

The play button is the centerpiece. It needs to feel physically pressable. Use a circular element with shadow-neu-flat at rest and shadow-neu-pressed on active. React's onMouseDown / onMouseUp gives you fine-grained control over the visual state separate from the actual playing state.

// Inside PodcastPlayer.tsx
const [held, setHeld] = useState(false);

<button
  onClick={toggle}
  onMouseDown={() => setHeld(true)}
  onMouseUp={() => setHeld(false)}
  onMouseLeave={() => setHeld(false)}
  aria-label={playing ? 'Pause episode' : 'Play episode'}
  className={`
    w-16 h-16 rounded-full bg-neu flex items-center justify-center
    transition-shadow duration-100
    ${held ? 'shadow-neu-pressed' : 'shadow-neu-flat'}
  `}
>
  {playing ? (
    <PauseIcon className="w-6 h-6 text-gray-600" />
  ) : (
    <PlayIcon className="w-6 h-6 text-gray-600 translate-x-0.5" />
  )}
</button>

That translate-x-0.5 on the play icon is intentional — SVG play triangles have optical centering issues inside perfect circles. Half a pixel right fixes it visually. Don't skip it.

Also: always include the aria-label toggle. Neumorphic interfaces can be tricky for screen readers because the visual cues are entirely shadow-based. That label is the accessible fallback. It's not optional.

Building a Neumorphic Scrubber and Progress Bar

A flat progress bar looks wrong in a neumorphic context. The right pattern is an inset track with a filled progress indicator inside — the track should look carved into the surface, and the fill should feel like it's sitting inside that groove.

// NeuScrubber.tsx
export function NeuScrubber({
  progress,
  onChange,
}: {
  progress: number;
  onChange: (pct: number) => void;
}) {
  const trackRef = useRef<HTMLDivElement>(null);

  const handleClick = (e: React.MouseEvent) => {
    if (!trackRef.current) return;
    const rect = trackRef.current.getBoundingClientRect();
    const pct = ((e.clientX - rect.left) / rect.width) * 100;
    onChange(Math.min(100, Math.max(0, pct)));
  };

  return (
    <div
      ref={trackRef}
      onClick={handleClick}
      role="slider"
      aria-valuenow={Math.round(progress)}
      aria-valuemin={0}
      aria-valuemax={100}
      className="w-full h-3 rounded-full shadow-neu-inset bg-neu cursor-pointer relative"
    >
      <div
        className="h-full rounded-full bg-gradient-to-r from-indigo-400 to-purple-400"
        style={{ width: `${progress}%` }}
      />
    </div>
  );
}

The gradient fill (from-indigo-400 to-purple-400) gives the progress indicator a bit of visual energy without breaking the muted neumorphic palette. Pure gray fills tend to disappear into the surface. A subtle color accent anchors the eye.

Why not use a native <input type="range">? You can, and it handles keyboard events automatically — which is great for accessibility. But styling it consistently across browsers with neumorphic shadows is painful enough that a custom div with a role="slider" and proper ARIA is often cleaner in practice.

Volume Knob and Skip Buttons in Soft UI

Skip 15 seconds back and forward are standard podcast controls. Style them as smaller neumorphic circles, visually subordinate to the main play button. A w-12 h-12 size next to a w-16 h-16 play button creates the right hierarchy without any color changes.

For volume, a horizontal slider using the same scrubber component works. But if you want something more distinctive, a circular knob works beautifully with neumorphism. You rotate a styled div based on the drag angle. It's a bit more JavaScript, but it reads as a physical control in a way sliders don't.

Check out how Empire UI handles theme toggle components in React — the pattern of swapping shadow classes on state change is exactly the same technique that makes neumorphic interactive elements feel responsive. Same mental model, different component.

Accessibility Traps in Neumorphic Audio UI

Neumorphism has a real accessibility problem that you should know going in: the low-contrast shadow system that makes it look great on a good monitor makes it almost invisible for users with visual impairments. WCAG 2.2 requires a 4.5:1 contrast ratio for text and 3:1 for UI components. Soft gray on gray often fails both.

There are a few ways to handle this. First, ensure your icon colors hit contrast minimums against the #e0e5ec background — text-gray-600 (#4b5563) gives you about 4.6:1, which just passes. Second, never rely solely on shadow direction to communicate state. Add a color accent for active states. The indigo gradient fill on the scrubber does exactly this — it's readable even if the shadow system isn't.

Third, test with keyboard navigation. Your play button should respond to Space and Enter. Your scrubber should respond to arrow keys when focused. These aren't extras. They're the baseline. If you're building this in Next.js, run an axe-core check in your development flow — catch these issues before they ship.

Dark Mode Neumorphism: Adjusting the Shadow System

Dark mode neumorphism is a different shadow system entirely. On light surfaces, the light shadow is white (rgba(255,255,255,0.8)) and the dark shadow is a muted gray. On dark surfaces — say #1e2a3a — you flip to a lighter shade for the highlight (rgba(255,255,255,0.05)) and a near-black for the depth shadow (rgba(0,0,0,0.4)). The contrast is much lower, which means dark neumorphism is more subtle.

Add these to your Tailwind config alongside the light-mode variants:

'neu-dark-flat': '6px 6px 12px rgba(0,0,0,0.4), -6px -6px 12px rgba(255,255,255,0.05)',
'neu-dark-pressed': 'inset 6px 6px 12px rgba(0,0,0,0.4), inset -6px -6px 12px rgba(255,255,255,0.05)',

Then use Tailwind's dark: prefix to swap classes. shadow-neu-flat dark:shadow-neu-dark-flat on each interactive element. It's verbose, but it works cleanly without JavaScript. If you're doing more complex UI style switching, comparing Tailwind vs CSS modules is worth a read before committing to either approach for your design token layer.

FAQ

Can I use neumorphism with a dark background color?

Yes, but the shadow values change significantly. On dark surfaces like #1e2a3a, use rgba(0,0,0,0.4) for the depth shadow and rgba(255,255,255,0.05) for the highlight. The effect is more subtle than light-mode neumorphism. Add Tailwind dark: prefix variants so both themes work from the same component.

How do I make a neumorphic button feel pressed without JavaScript?

Use the CSS :active pseudo-class and swap the box-shadow to the inset variant. In Tailwind v4.0.2 you'd write active:shadow-neu-pressed directly in the className. No JS state needed for pure visual feedback, though React state gives you more control over timing and the playing/paused toggle.

Does neumorphism pass WCAG accessibility contrast requirements?

The shadow system itself doesn't carry semantic meaning and isn't tested for contrast. What does matter is your icon and text colors against the background. Use text-gray-600 (#4b5563) on #e0e5ec for roughly 4.6:1 contrast, which passes WCAG AA. Always add ARIA roles and labels — don't rely on shadows to communicate state to assistive tech.

Why does my neumorphic shadow get clipped on rounded elements?

Shadows on elements with overflow:hidden or inside containers with overflow:hidden get clipped. Make sure the parent container has enough padding — at least 8px more than your shadow spread value — and doesn't clip overflow. With a 12px shadow spread, you need at least p-4 on the wrapper or the shadow edges will cut off.

Can I animate the play button shadow transition smoothly?

Yes, add transition-shadow duration-100 to the button element. 100ms is fast enough to feel responsive without being jarring. For the press-and-hold feel, use onMouseDown/onMouseUp handlers in React to swap between shadow-neu-flat and shadow-neu-pressed classes, which the transition property will animate automatically.

What's the right background color for neumorphic UI?

#e0e5ec is the most commonly used base. It's light enough for white highlights to show, dark enough for the gray shadows to read clearly. Avoid pure white (#ffffff) because the highlight shadow vanishes. If you need a warmer palette, try #ece8e0 and adjust your shadow colors to use warm-tinted grays rather than neutral ones.

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

Read next

Light Mode Neumorphism: Making Soft UI Work on White BackgroundsSoft UI Progress Bar: Neumorphic Loading IndicatorsComponent State Design: Default, Hover, Active, Disabled, ErrorTailwind Button Collection: 15 Variants for Every Use Case