Neumorphism Volume Control: Media Slider with Soft UI
Build a neumorphism volume control slider in React with Tailwind. Soft UI shadows, accessible range inputs, and clean media player aesthetics in one component.
Why a Volume Slider is the Perfect Neumorphism Demo
Honestly, neumorphism's whole identity is about physical objects rendered in UI — and a volume knob or slider is one of the most physical controls you can imagine. It begs to be touched. That tactile quality is exactly what the soft UI aesthetic tries to recreate on flat screens.
A media player volume control gives you an excuse to use every signature trick: concave track wells, convex thumb buttons, the dual-shadow technique that simulates depth. If you've read our breakdown of what neumorphism actually is, you'll know the style lives or dies on its shadow math. A slider is a concentrated stress test of that math.
We're building this from scratch with React and Tailwind v4.0.2. No library dependencies for the visual layer. The HTML range input does the real work underneath; we're just dressing it up.
The Dual-Shadow Formula: Making Depth Feel Real
The neumorphism effect needs two shadows cast from opposite corners — one light, one dark. The background color sits exactly between those two extremes. That's non-negotiable. If your background is #e0e5ec, your light shadow should be something like rgba(255,255,255,0.8) and your dark shadow rgba(163,177,198,0.6). Tweak those alpha values and the whole illusion collapses.
For a volume slider track, you want a concave (inset) shadow to simulate a groove carved into the surface. The thumb should be convex — pushing outward. Inverting the shadow direction between those two elements is what sells it. It's a small detail but users feel it even when they can't articulate why.
Where does this compare to glassmorphism? Completely different mechanics. If you want to understand the philosophical split, our neumorphism vs glassmorphism comparison covers it in depth. Short answer: glass uses blur and transparency; neumorphism uses shadow and monochromatic color.
Building the Soft UI Volume Slider in React
Let's write the actual component. The approach is a native <input type="range"> hidden behind a styled overlay. We track the value in state, draw a filled portion using a CSS custom property, and layer the neumorphic shadows on top.
import { useState, useRef } from 'react';
export function VolumeSlider() {
const [volume, setVolume] = useState(65);
const trackRef = useRef<HTMLDivElement>(null);
return (
<div className="flex flex-col items-center gap-4 p-8"
style={{ background: '#e0e5ec', borderRadius: '20px',
boxShadow: '8px 8px 16px rgba(163,177,198,0.6), -8px -8px 16px rgba(255,255,255,0.8)' }}>
{/* Volume icon + label */}
<div className="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="#7a8ba0" strokeWidth="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
{volume > 0 && <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />}
{volume > 50 && <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />}
</svg>
<span className="text-sm font-medium" style={{ color: '#7a8ba0' }}>
{volume}%
</span>
</div>
{/* Track container — inset shadow for the groove */}
<div ref={trackRef}
className="relative w-64 h-3 rounded-full"
style={{
background: '#e0e5ec',
boxShadow: 'inset 4px 4px 8px rgba(163,177,198,0.6), inset -4px -4px 8px rgba(255,255,255,0.8)'
}}>
{/* Filled portion */}
<div className="absolute top-0 left-0 h-full rounded-full"
style={{
width: `${volume}%`,
background: 'linear-gradient(90deg, #6c8ebf, #8ab4f8)',
boxShadow: '0 0 8px rgba(108,142,191,0.5)'
}} />
{/* Native range input — transparent, sits on top */}
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
aria-label="Volume"
style={{ margin: 0 }}
/>
{/* Custom thumb */}
<div className="absolute top-1/2 -translate-y-1/2 w-5 h-5 rounded-full pointer-events-none"
style={{
left: `calc(${volume}% - 10px)`,
background: '#e0e5ec',
boxShadow: '3px 3px 6px rgba(163,177,198,0.6), -3px -3px 6px rgba(255,255,255,0.9)'
}} />
</div>
</div>
);
}The transparent native input on top is the accessibility trick. Screen readers see a real range input. Mouse and touch events are native. We don't reinvent any interaction logic — just the visuals. That's the right separation of concerns.
CSS Custom Properties for the Shadow System
Hardcoding shadow values inline gets messy fast. The moment a designer says 'let's make it darker' you're doing a grep-and-replace across five components. Define your neumorphic shadow system as CSS custom properties at the :root level and everything snaps into place.
:root {
--neu-bg: #e0e5ec;
--neu-shadow-dark: rgba(163, 177, 198, 0.6);
--neu-shadow-light: rgba(255, 255, 255, 0.8);
--neu-distance: 8px;
--neu-blur: 16px;
--neu-raised:
var(--neu-distance) var(--neu-distance) var(--neu-blur) var(--neu-shadow-dark),
calc(-1 * var(--neu-distance)) calc(-1 * var(--neu-distance)) var(--neu-blur) var(--neu-shadow-light);
--neu-inset:
inset var(--neu-distance) var(--neu-distance) var(--neu-blur) var(--neu-shadow-dark),
inset calc(-1 * var(--neu-distance)) calc(-1 * var(--neu-distance)) var(--neu-blur) var(--neu-shadow-light);
}
.neu-card { box-shadow: var(--neu-raised); background: var(--neu-bg); }
.neu-groove { box-shadow: var(--neu-inset); background: var(--neu-bg); }
.neu-thumb {
--neu-distance: 3px;
--neu-blur: 6px;
box-shadow: var(--neu-raised);
background: var(--neu-bg);
}Notice the --neu-distance override on .neu-thumb. The thumb uses a smaller offset — 3px instead of 8px — because it's a smaller element. Proportionality matters in neumorphism. A massive shadow on a tiny button looks broken immediately. Scale your distances to your element sizes.
Dark Mode Neumorphism: Yes, It Can Work
Neumorphism has a reputation for being a light-mode-only style. That reputation is mostly deserved — the original soft UI demonstrations from 2020 all used pale gray backgrounds. But dark mode neumorphism is absolutely doable if you flip the shadow colors correctly.
On a dark background like #1e2a3a, your light shadow becomes rgba(45,65,90,0.7) (lighter than background) and your dark shadow rgba(10,15,25,0.8) (darker than background). The math is identical, the palette just inverts. If you're already building a theme toggle in React, you can swap those CSS custom property values on the :root[data-theme='dark'] selector and neumorphism transitions cleanly.
The tricky part is contrast. Light-mode neumorphism already struggles with accessibility — the WCAG contrast ratios are borderline on a monochromatic palette. Dark mode compounds this. You'll need to push your text colors and icon strokes harder than you think. Don't skip the contrast check.
Accessibility: Range Inputs Aren't Optional
Can we talk about the elephant in the room? A lot of neumorphism demos online replace the native range input with a fully custom drag handler. That means zero keyboard support, zero screen reader announcements, zero aria attributes. It looks slick in a Dribbble shot. It's unusable for anyone navigating by keyboard.
The pattern in our code above — transparent native input over a custom visual — gives you accessibility for free. Arrow keys change volume by 1 step. Home/End jump to 0 and 100. Screen readers announce 'Volume 65%' without you writing a single aria attribute beyond aria-label. That's the deal you want.
If you need a vertical volume slider (like a mixer fader), add appearance: slider-vertical and rotate the custom visuals with transform: rotate(-90deg). The native input handles all the math; your visual layer just needs to follow along. Also worth noting: Tailwind v4.0.2's accent-color utility can skin the native thumb if you don't want the full custom overlay approach. Less control, way less code.
Animating the Volume Thumb with Framer Motion
The static component looks good. The animated one looks great. A small spring animation on the thumb as it moves makes the whole thing feel more physical — which is the entire point of neumorphism as a style. You want it to feel like you're pushing a real button.
import { motion } from 'framer-motion';
// Replace the static thumb div with:
<motion.div
className="absolute top-1/2 -translate-y-1/2 w-5 h-5 rounded-full pointer-events-none"
style={{
left: `calc(${volume}% - 10px)`,
background: '#e0e5ec',
boxShadow: '3px 3px 6px rgba(163,177,198,0.6), -3px -3px 6px rgba(255,255,255,0.9)'
}}
animate={{
scale: isDragging ? 1.2 : 1,
boxShadow: isDragging
? '5px 5px 10px rgba(163,177,198,0.7), -5px -5px 10px rgba(255,255,255,0.95)'
: '3px 3px 6px rgba(163,177,198,0.6), -3px -3px 6px rgba(255,255,255,0.9)'
}}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
/>Track isDragging state with onMouseDown / onMouseUp on the native input. When dragging starts, the thumb scales up slightly and the shadow deepens — simulating a button being pressed out further. When you release, the spring eases it back. Stiffness 400 and damping 25 feels snappy without bouncing. Adjust those values if you want more or less physicality.
When to Use Neumorphism in Real Projects
Neumorphism isn't a style you plaster across an entire app. It works as an accent — isolated widgets like media controls, card components, toggle buttons. A full dashboard built in neumorphism is hard to scan and even harder to maintain. Use it for the moments that deserve tactile attention.
Media players are one of those moments. Smart home dashboards. Audio production tools. Anything where the user is operating controls and expects physical feedback. For the surrounding UI you might use a standard design system, then drop in neumorphic components where that physicality adds value.
Compare this to claymorphism or neobrutalism — those styles can scale to whole pages. Neumorphism is best as seasoning. And if you're uncertain which style fits your project, all of these are available in Empire UI's 40-style library, ready to drop in and compare side by side before committing.
FAQ
Almost always a background color mismatch. Your element background must exactly match the shadow base color. If your card is #e0e5ec but your slider track is #ffffff, the shadows won't simulate depth — they'll just look like colored outlines. Set the same background on every neumorphic element.
Target ::-webkit-slider-thumb for Chromium and ::-moz-range-thumb for Firefox. Set appearance: none first on the input itself, then on the pseudo-element. You can apply box-shadow and border-radius from there. It works for simple cases but the cross-browser inconsistency is why most neumorphism implementations use the transparent-overlay pattern instead.
For elements under 24px, keep shadow distance at 2-3px and blur at 4-6px. Going larger than that on small elements makes them look smeared rather than dimensional. The ratio that works well is: shadow offset = element size divided by 8, blur = offset times 2.
Yes, but you'll need to extend your Tailwind config with custom shadow values. Add them under theme.extend.boxShadow in tailwind.config.ts. In Tailwind v4.0.2 you can also define them as CSS custom properties in your @theme block and reference them with arbitrary value syntax like shadow-[var(--neu-raised)]. The CSS custom property approach is cleaner for a shadow system you'll reuse.
Usually not out of the box. The monochromatic palette means text and interactive elements often fall below the 4.5:1 ratio for normal text and 3:1 for large text. You'll need to use stronger text colors than the shadows suggest, add visible focus rings (don't rely on the shadow for focus indication), and avoid conveying state changes through shadow alone.
Add writing-mode: vertical-lr and direction: rtl to the native input element, then set a fixed height instead of width. The browser reorients the range control natively. For the custom visual overlay, rotate the container with transform: rotate(180deg) if the direction feels backwards. Adjust your thumb position calculation to use top instead of left.