Neumorphism Music Player: Soft UI Controls With Inset Shadows
Build a fully interactive neumorphism music player in React with inset shadows, soft-UI buttons, and a progress slider — no design tools needed.
Why a Music Player Is the Perfect Neumorphism Demo
Neumorphism lives or dies by its interaction states. Play/pause buttons, volume sliders, progress tracks — they're all controls that need a pressed state, and that's exactly where inset shadows shine. A flat button can look neumorphic. But the moment you press it and those shadows invert to push the surface inward, the metaphor clicks. You feel it.
Most neumorphism tutorials stop at a static card. That's fine for learning the shadow math, but it doesn't show you what the style is actually *for*. A music player forces you to handle :active states, range input styling, icon swaps, and conditional shadow toggling — all in a single component. Build one and you've covered 90% of what neumorphism asks of you in production.
Honestly, the music player is also just a classic UI pattern that every developer recognises immediately. There's no ambiguity about what the buttons should do. That makes it a great benchmark: if your neumorphic shadow system looks natural on play/skip/volume controls, it'll translate to any other widget you build. Check out the full neumorphism style guide on Empire UI if you want the complete token set before we start.
One more thing — this article targets React with plain CSS custom properties. No Tailwind, no styled-components. Sometimes you want to see exactly which CSS declarations produce the effect, without a utility layer abstracting it away.
The Shadow Formula: Lifted vs. Pressed
Neumorphism uses two shadow directions simultaneously. A light source sits at the top-left, so the top-left corner of a raised element catches light (lighter shadow) and the bottom-right falls into shade (darker shadow). Flip both shadows to inset when the element is pressed. That's the whole system — two box-shadows, two states.
In CSS it looks like this for a background color of #e0e5ec (the canonical soft grey used in neumorphism since at least 2020):
``css
:root {
--bg: #e0e5ec;
--shadow-light: #ffffff;
--shadow-dark: #a3b1c6;
--shadow-distance: 8px;
--shadow-blur: 16px;
}
/* Raised — element sits above the surface */
.neu-raised {
background: var(--bg);
box-shadow:
var(--shadow-distance) var(--shadow-distance) var(--shadow-blur) var(--shadow-dark),
calc(-1 * var(--shadow-distance)) calc(-1 * var(--shadow-distance)) var(--shadow-blur) var(--shadow-light);
}
/* Pressed — element pushed into the surface */
.neu-pressed {
background: var(--bg);
box-shadow:
inset var(--shadow-distance) var(--shadow-distance) var(--shadow-blur) var(--shadow-dark),
inset calc(-1 * var(--shadow-distance)) calc(-1 * var(--shadow-distance)) var(--shadow-blur) var(--shadow-light);
}
``
Worth noting: the shadow distance and blur ratio matters a lot. At 8px distance / 16px blur you get a gentle lift. Push to 12px / 24px and it starts looking exaggerated — acceptable for large hero cards, weird for small icon buttons. For 40px × 40px transport controls, stay at 4px / 8px or the shadows start bleeding into adjacent elements.
The background color isn't really grey — it's a slightly cool, slightly blue grey. #e0e5ec pulls toward blue which gives the light shadow (#ffffff) and dark shadow (#a3b1c6) a coherent temperature. If you swap in a warm beige background and keep the same shadow colors, everything looks wrong. You can experiment with background tones using the box shadow generator, which lets you tweak shadow offset and blur live.
Building the Player Shell in React
Start with the container and track info. The player card itself should be raised, the artwork inset, and the text just sits flat on the surface:
``tsx
// NeuMusicPlayer.tsx
import { useState, useRef } from 'react';
import styles from './NeuMusicPlayer.module.css';
interface Track {
title: string;
artist: string;
duration: number; // seconds
artUrl: string;
}
const DEMO_TRACK: Track = {
title: 'Midnight Circuit',
artist: 'Echo Drift',
duration: 217,
artUrl: '/covers/midnight-circuit.jpg',
};
export function NeuMusicPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); // 0–1
const [volume, setVolume] = useState(0.7);
return (
<div className={styles.playerCard}>
<div className={styles.artwork}>
<img src={DEMO_TRACK.artUrl} alt={DEMO_TRACK.title} />
</div>
<div className={styles.trackInfo}>
<h2 className={styles.trackTitle}>{DEMO_TRACK.title}</h2>
<p className={styles.artistName}>{DEMO_TRACK.artist}</p>
</div>
{/* controls go here */}
</div>
);
}
``
The artwork container gets the inset treatment — it should look like a screen sunk into the device surface. That's a common neumorphism pattern: decorative containers are pressed, interactive controls are raised until active. It's a spatial hierarchy that tells users what they can touch.
Quick aside: the component above is uncontrolled — it doesn't wire up a real <audio> element. For a real player you'd attach a useRef<HTMLAudioElement> and drive currentTime and volume from there. For this article we're focused on the visual layer, so progress and volume are just local state that drive the slider appearance.
Split the CSS into a module file. Custom properties on :root let you theme dark mode later without touching component markup — just swap the --bg, --shadow-light, and --shadow-dark values under a [data-theme='dark'] selector.
Neumorphic Transport Controls: Play, Skip, Shuffle
The play/pause button is the centerpiece. It should be larger than skip buttons — 64px vs 44px is a good ratio — and its active state needs an immediate visual response. No delays. No animations longer than 100ms. Users pressing play expect tactile feedback faster than they'd notice a 200ms transition.
``css
/* NeuMusicPlayer.module.css */
.playerCard {
background: var(--bg, #e0e5ec);
border-radius: 24px;
padding: 32px;
box-shadow:
8px 8px 16px var(--shadow-dark, #a3b1c6),
-8px -8px 16px var(--shadow-light, #ffffff);
width: 320px;
}
.artwork {
border-radius: 16px;
overflow: hidden;
box-shadow:
inset 4px 4px 8px var(--shadow-dark, #a3b1c6),
inset -4px -4px 8px var(--shadow-light, #ffffff);
margin-bottom: 24px;
}
.artwork img {
width: 100%;
display: block;
}
.controlsRow {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 24px;
}
.btnSmall,
.btnPlay {
background: var(--bg, #e0e5ec);
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow 80ms ease, transform 80ms ease;
}
.btnSmall {
width: 44px;
height: 44px;
box-shadow:
4px 4px 8px var(--shadow-dark, #a3b1c6),
-4px -4px 8px var(--shadow-light, #ffffff);
}
.btnPlay {
width: 64px;
height: 64px;
box-shadow:
6px 6px 12px var(--shadow-dark, #a3b1c6),
-6px -6px 12px var(--shadow-light, #ffffff);
}
.btnSmall:active,
.btnSmall.active {
box-shadow:
inset 4px 4px 8px var(--shadow-dark, #a3b1c6),
inset -4px -4px 8px var(--shadow-light, #ffffff);
}
.btnPlay:active,
.btnPlay.isPlaying {
box-shadow:
inset 6px 6px 12px var(--shadow-dark, #a3b1c6),
inset -6px -6px 12px var(--shadow-light, #ffffff);
}
``
Note the .btnPlay.isPlaying class. The play button stays pressed — with inset shadows — while audio is playing. That's a toggle control, not a momentary one. Skip buttons are momentary: they go pressed on :active then return to raised. This distinction is important for usability. A user glancing at the player should be able to tell immediately if it's playing from the button state alone.
In the React component, wire the play button's class to state:
``tsx
<div className={styles.controlsRow}>
<button
className={styles.btnSmall}
aria-label="Previous track"
onClick={() => setProgress(0)}
>
<SkipBackIcon size={18} />
</button>
<button
className={${styles.btnPlay} ${isPlaying ? styles.isPlaying : ''}}
aria-label={isPlaying ? 'Pause' : 'Play'}
onClick={() => setIsPlaying(p => !p)}
>
{isPlaying ? <PauseIcon size={24} /> : <PlayIcon size={24} />}
</button>
<button
className={styles.btnSmall}
aria-label="Next track"
>
<SkipForwardIcon size={18} />
</button>
</div>
``
In practice, neumorphism icon colors need to match the shadow palette temperature. Use #6b7a99 for icons on the #e0e5ec background — that's roughly 20% darker and blue-shifted. Pure black icons look wrong, same with pure grey. The color temperature has to stay consistent or the whole surface looks patchy.
Styling the Progress Slider and Volume Knob
Range inputs are notoriously painful to style cross-browser. Neumorphism makes it worse because you need the track itself to be inset (like a groove carved into the surface) while the thumb should be raised. You're wrestling with two completely different pseudo-elements in the same control.
Here's a cross-browser range input that works in Chrome 126+, Firefox 125+, and Safari 17+:
``css
.progressTrack {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--bg, #e0e5ec);
box-shadow:
inset 2px 2px 4px var(--shadow-dark, #a3b1c6),
inset -2px -2px 4px var(--shadow-light, #ffffff);
outline: none;
cursor: pointer;
}
/* Webkit thumb */
.progressTrack::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--bg, #e0e5ec);
box-shadow:
3px 3px 6px var(--shadow-dark, #a3b1c6),
-3px -3px 6px var(--shadow-light, #ffffff);
cursor: pointer;
transition: box-shadow 80ms ease;
}
.progressTrack::-webkit-slider-thumb:active {
box-shadow:
inset 3px 3px 6px var(--shadow-dark, #a3b1c6),
inset -3px -3px 6px var(--shadow-light, #ffffff);
}
/* Firefox thumb */
.progressTrack::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: var(--bg, #e0e5ec);
box-shadow:
3px 3px 6px var(--shadow-dark, #a3b1c6),
-3px -3px 6px var(--shadow-light, #ffffff);
cursor: pointer;
}
``
The volume knob is the same range input, just rotated 90 degrees if you want a vertical orientation — transform: rotate(-90deg) on the element, then adjust the parent dimensions to match. Or keep it horizontal below the progress bar. Either way, the CSS is identical.
One more thing — the filled portion of the progress track (showing how far through the track you are) is tricky in plain CSS. The cleanest approach is to compute a background gradient from state: background: linear-gradient(to right, #6b7a99 ${progress * 100}%, #e0e5ec ${progress * 100}%). You apply this inline in React, overriding the base module class only for the fill. The inset shadow on the track still shows through the transparent parts of the gradient.
Look, range input cross-browser styling has been broken since 2012 and it's still not fully fixed in 2026. The Webkit/Firefox split for thumb pseudo-elements is the main headache. Accept that you'll write the thumb styles twice and move on — it's a one-time cost per project.
Dark Mode Neumorphism: Flipping the Palette
Dark neumorphism doesn't just invert the colors. On a dark surface, the light shadow becomes a subtle highlight (not full white — that would look like neon) and the dark shadow becomes nearly black. The background shifts to around #1e2130 for a dark blue-grey tone that preserves the shadow temperature.
[data-theme='dark'] {
--bg: #1e2130;
--shadow-light: #2a3050;
--shadow-dark: #12151e;
}That's it. If you built the component with CSS custom properties from the start, dark mode is three lines. If you hardcoded the colors, you're rewriting every shadow declaration. That's the only reason I push the custom property approach — not aesthetics, pure maintenance.
Worth noting: dark neumorphism is more forgiving of contrast issues than light neumorphism. On #e0e5ec, your text has to work hard to hit WCAG AA contrast. On #1e2130, white text at rgba(255,255,255,0.9) clears the 4.5:1 ratio easily. That makes dark mode the safer default if you're shipping this to production and can't run a full accessibility audit immediately. You can also browse the neumorphism collection on Empire UI — several of the pre-built components include dark variants you can fork.
Toggle theme by setting document.documentElement.setAttribute('data-theme', 'dark') or wiring it to a neumorphic toggle switch (which, obviously, should itself use the pressed/raised state to indicate its on/off position — it's toggles all the way down).
Putting It All Together: Full Component Reference
Here's the complete JSX for the player — controls wired, progress slider functional, dark mode ready. Copy it into your project and swap in your own track data and icon components:
``tsx
// NeuMusicPlayer.tsx — complete
import { useState } from 'react';
import styles from './NeuMusicPlayer.module.css';
export function NeuMusicPlayer({ track }: { track: Track }) {
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [volume, setVolume] = useState(0.7);
const formatTime = (ratio: number) => {
const secs = Math.round(ratio * track.duration);
return ${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')};
};
const progressStyle = {
background: linear-gradient(to right, #6b7a99 ${progress * 100}%, var(--bg) ${progress * 100}%),
};
return (
<div className={styles.playerCard}>
<div className={styles.artwork}>
<img src={track.artUrl} alt={track.title} />
</div>
<div className={styles.trackInfo}>
<h2 className={styles.trackTitle}>{track.title}</h2>
<p className={styles.artistName}>{track.artist}</p>
</div>
<input
type="range"
min={0} max={1} step={0.001}
value={progress}
onChange={e => setProgress(Number(e.target.value))}
className={styles.progressTrack}
style={progressStyle}
aria-label="Playback progress"
/>
<div className={styles.timeRow}>
<span>{formatTime(progress)}</span>
<span>{formatTime(1)}</span>
</div>
<div className={styles.controlsRow}>
<button className={styles.btnSmall} aria-label="Previous">
{/* <SkipBackIcon /> */}
</button>
<button
className={${styles.btnPlay} ${isPlaying ? styles.isPlaying : ''}}
onClick={() => setIsPlaying(p => !p)}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{/* isPlaying ? <PauseIcon /> : <PlayIcon /> */}
</button>
<button className={styles.btnSmall} aria-label="Next">
{/* <SkipForwardIcon /> */}
</button>
</div>
<div className={styles.volumeRow}>
<span className={styles.volLabel}>VOL</span>
<input
type="range"
min={0} max={1} step={0.01}
value={volume}
onChange={e => setVolume(Number(e.target.value))}
className={styles.progressTrack}
aria-label="Volume"
/>
</div>
</div>
);
}
``
The icon comments are placeholders — swap in lucide-react, react-icons, or your own SVGs. The shadows and state logic are the point, not which icon library you pick.
If you want a faster path to something production-polished, the Empire UI component library ships neumorphic form elements including sliders and icon buttons. You can pull those primitives and build the player shell yourself, or grab a full template from /templates and customise from there. Either way you're not reinventing the shadow math from scratch.
That said, building it once yourself — like we just did — is how you actually understand *why* the shadows are sized the way they are. Then you'll spot immediately when a library's neumorphism looks off. That instinct is worth the hour.
FAQ
The effect depends on screen calibration and ambient lighting. If your monitor's gamma is high or you're in a bright room, the shadow contrast feels weak. Try increasing --shadow-dark opacity or deepening the dark shadow color from #a3b1c6 to #8a98b5 — just 10–15% darker makes a big difference on uncalibrated displays.
Yes, but you have to derive your shadow colors from the background programmatically. For a background of #d4e4f7 (soft blue), your light shadow stays near #ffffff and your dark shadow shifts to #9fb8d0. Tools like the box shadow generator let you preview this live.
It's the hardest soft-UI style to make accessible. The low-contrast surfaces fail WCAG AA for text almost by default. You need explicit, high-contrast text colors and you shouldn't rely on shadow alone to communicate interactive state — always pair it with an icon change or color accent. Test with a screen reader too, because the visual feedback is entirely invisible to assistive tech.
Add transition: box-shadow 80ms ease to the button. Don't go longer than 120ms — anything slower makes the UI feel laggy. The shadow flip is subtle enough that a longer transition doesn't add elegance, it just adds delay.