Soft UI Progress Bar: Neumorphic Loading Indicators
Build neumorphic progress bars with soft shadows and inset depth effects. Real code, Tailwind v4 patterns, and CSS tricks that actually work in production.
What Makes a Progress Bar "Soft UI"
Honestly, most progress bars are an afterthought — a flat colored rectangle that screams "I copied this from Bootstrap and never looked back." Soft UI progress bars are different. They sit in the surface instead of on top of it, using inset box shadows to carve a recessed track and then filling that track with a raised, glowing indicator.
The visual trick is simple once you see it. The outer track gets an inset box shadow that makes it appear pressed into the page — typically something like inset 4px 4px 8px rgba(163,177,198,0.6), inset -4px -4px 8px rgba(255,255,255,0.5). The fill bar inside gets the opposite treatment: outward shadows that make it pop forward. Two shadow layers, opposite directions, completely different feel from a flat div.
If you've already read what is neumorphism and understood the core shadow technique, progress bars are a natural next step. They're one of the few UI elements where neumorphism actually communicates something useful — the depth metaphor maps directly onto the concept of progress being "filled in."
The CSS Foundation: Inset Shadows and Raised Fills
Before touching React or Tailwind, it's worth nailing the pure CSS model. The background color matters a lot here. Neumorphism only works on mid-tone surfaces — you need room to go lighter and darker from a base. #e0e5ec is a solid choice, or #dde1e7 if you want slightly cooler tones.
Here's the core CSS you'll be building on:
.soft-track {
background: #e0e5ec;
border-radius: 50px;
box-shadow:
inset 4px 4px 8px rgba(163, 177, 198, 0.6),
inset -4px -4px 8px rgba(255, 255, 255, 0.5);
height: 14px;
width: 100%;
overflow: hidden;
}
.soft-fill {
height: 100%;
border-radius: 50px;
background: linear-gradient(90deg, #a8b8d8 0%, #8fa3c8 100%);
box-shadow:
2px 2px 5px rgba(163, 177, 198, 0.7),
-2px -2px 5px rgba(255, 255, 255, 0.4);
transition: width 0.4s ease;
}Notice the overflow: hidden on the track — without it, the fill bar's outward shadow bleeds outside the rounded corners and breaks the illusion. Also notice that the fill uses a subtle gradient rather than a flat color. Flat fills inside an inset track look disconnected; the gradient ties the lighting model together.
Building the React Component with Tailwind v4
Tailwind v4.0.2 made arbitrary shadow values much cleaner with the updated shadow-[...] syntax. You can express the full neumorphic shadow stack inline without touching a custom CSS file. It's verbose, but it works, and it colocates the visual logic with the component.
interface SoftProgressBarProps {
value: number; // 0-100
label?: string;
color?: 'blue' | 'purple' | 'green';
}
const colorMap = {
blue: 'from-[#a8b8d8] to-[#7a9bc8]',
purple: 'from-[#c8a8d8] to-[#a87ac8]',
green: 'from-[#a8d8b8] to-[#7ac896]',
};
export function SoftProgressBar({
value,
label,
color = 'blue',
}: SoftProgressBarProps) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className="w-full">
{label && (
<div className="mb-2 flex justify-between text-sm text-[#8fa3c8]">
<span>{label}</span>
<span>{clamped}%</span>
</div>
)}
<div
className="h-3.5 w-full overflow-hidden rounded-full bg-[#e0e5ec]"
style={{
boxShadow:
'inset 4px 4px 8px rgba(163,177,198,0.6), inset -4px -4px 8px rgba(255,255,255,0.5)',
}}
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`h-full rounded-full bg-gradient-to-r ${colorMap[color]} transition-[width] duration-500 ease-out`}
style={{
width: `${clamped}%`,
boxShadow:
'2px 2px 5px rgba(163,177,198,0.7), -2px -2px 5px rgba(255,255,255,0.4)',
}}
/>
</div>
</div>
);
}The style prop for the shadow values is intentional — Tailwind's arbitrary shadow syntax gets unwieldy with multi-value inset shadows, and splitting it into style keeps the JSX readable. Everything else stays in Tailwind classes. The transition-[width] class uses Tailwind v4's expanded transition support to animate the fill width smoothly on value changes.
Animated Indeterminate State for Loading Scenarios
A progress bar that shows 0–100% covers one use case. But what about loading states where you don't have a percentage — fetching data, waiting for a server response? You need an indeterminate variant, and soft UI makes that look particularly good.
The trick is animating a gradient sweep across the track rather than a fixed-width fill. A @keyframes animation shifts the background-position of a wider gradient, creating a shimmer that implies activity without a defined value.
@keyframes soft-shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.soft-indeterminate {
height: 100%;
border-radius: 50px;
width: 100%;
background: linear-gradient(
90deg,
#e0e5ec 0%,
#a8b8d8 25%,
#c8d5e8 50%,
#a8b8d8 75%,
#e0e5ec 100%
);
background-size: 200% auto;
animation: soft-shimmer 1.8s linear infinite;
}In React, you'd toggle between the determinate and indeterminate render based on whether value is undefined or a number. Keep it in the same component — a single SoftProgressBar that handles both cases is easier to maintain than two separate components.
Dark Mode Adaptation: Where Neumorphism Gets Tricky
Here's the thing: neumorphism on dark backgrounds is genuinely hard to pull off. The light/shadow duality that works on #e0e5ec doesn't translate to #1a1a2e or similar dark surfaces without adjusting the shadow colors entirely. The white highlight shadow needs to become a very subtle lighter version of the dark base, and the dark shadow needs to go even darker.
For dark mode soft UI progress bars, swap to something like inset 4px 4px 8px rgba(0,0,0,0.4), inset -4px -4px 8px rgba(255,255,255,0.04). The light shadow is barely there — just rgba(255,255,255,0.04). Push it too high and it looks like a glitch rather than a highlight. You can read more about why this design system creates these tradeoffs in glassmorphism vs neumorphism, which covers how different approaches handle dark surfaces.
One pattern that works: pair the dark neumorphic track with a slightly glowing fill. A fill with box-shadow: 0 0 12px rgba(139,168,224,0.5) gives it that lit-from-within quality that dark-mode users expect. It's subtle but it reads clearly. You'll also want to wire this to your theme toggle in React so the shadow values switch correctly when the user changes modes.
Don't try to use CSS filters or backdrop-filter here — the performance cost isn't worth it for a progress bar, and it causes compositing layer issues in lists or dashboards with many instances on screen at once.
Accessibility Considerations for Soft UI Indicators
Visual depth effects are great until they fail a contrast check. The inset shadow technique relies heavily on surface color — and if that surface color is too close to the fill color in luminance, users with low vision or color blindness can't distinguish the filled from unfilled portions. Run your color combinations through WCAG 2.1 contrast tools before shipping.
ARIA is non-negotiable. The role="progressbar", aria-valuenow, aria-valuemin, and aria-valuemax attributes you saw in the component above aren't optional decoration. Screen readers rely on them. If you're building an indeterminate bar, drop aria-valuenow and add aria-busy="true" on the container element to signal an ongoing process.
What about motion? The shimmer animation can trigger vestibular issues. Wrap any CSS animation in a prefers-reduced-motion media query and fall back to a static state or a very slow pulse. In Tailwind v4, the motion-safe: variant handles this cleanly: motion-safe:animate-[soft-shimmer_1.8s_linear_infinite].
Stacking Multiple Bars: Dashboard and SaaS Layouts
Individual progress bars are fine. A dashboard with eight of them — storage usage, CPU load, team capacity, API quota — is where the design really needs to hold together. Consistent spacing matters more than you'd think. An 8px gap between the label row and the track, 24px between each bar group, keeps things scannable.
For stacked layouts, consider adding color coding while keeping the same shadow language. All bars share the same track style; only the fill gradient changes. This consistency ties the design together visually and avoids the chaotic look you get when every bar has a different shadow depth or border-radius.
If you're building this into a SaaS dashboard, look at how best free glassmorphism components handles card containers — wrapping your soft UI progress bars inside a glassmorphic card creates an interesting layered effect where the card floats above the background and the bars are pressed into the card surface. Two different depth metaphors coexisting on the same page, which sounds wrong but actually reads well in practice.
Performance Notes: Shadow Rendering at Scale
Box shadows trigger paint operations. One or two progress bars on a page — no problem. A virtualized list with hundreds of them, or an auto-updating dashboard that re-renders bars every second — that's where you'll notice jank on lower-end hardware.
The fastest fix is will-change: transform on the fill element. It promotes the element to its own compositing layer, so width transitions happen on the GPU rather than triggering full repaints. Use it sparingly — promoting too many elements eats GPU memory — but for an animated fill bar it's exactly the right trade-off.
If you're seeing performance issues with many simultaneous bars, also consider whether you need the outward shadow on the fill at all. The inset shadow on the track is the primary visual effect; the fill shadow is a nice-to-have that adds maybe 15% to the paint cost of each bar. Dropping it on mobile or in reduced-motion contexts is a reasonable call. And if you're curious how this compares to other CSS-heavy techniques, particles background in React covers the canvas vs DOM rendering tradeoffs in more depth — the principles apply here too.
FAQ
Yes, but you have to rebuild the shadow values from scratch for dark surfaces. Replace the white highlight shadow with rgba(255,255,255,0.04) or similar near-zero opacity, and use rgba(0,0,0,0.4) for the dark shadow. The effect is more subtle on dark backgrounds but still works. Test on your actual dark background color — the shadows are sensitive to base color.
At very low values the fill element is too narrow to show the outward shadow properly, and at 100% the shadow clips against the track edges. For values below about 8%, suppress the fill's box-shadow entirely or use a minimum width of 8px to avoid the clipped look. At 100%, the track's overflow:hidden clips it cleanly so that edge is usually fine.
Set the width via inline style (not a class swap) and use a CSS transition on the width property. React batch-updates state, so if you're driving the value from a timer or API poll, wrap updates in a transition or use a debounce of around 50–100ms to avoid rapid repaints. The CSS transition handles the smooth interpolation; React just needs to set the final target value.
Beyond the standard ARIA attributes (role=progressbar, aria-valuenow, aria-valuemin, aria-valuemax), add an aria-label or aria-labelledby pointing to a descriptive string like 'Storage usage: 74%'. For indeterminate bars, remove aria-valuenow and set aria-busy=true on a parent container. Some screen readers also benefit from a live region that announces when progress hits 100%.
Tailwind v3.3+ supports arbitrary values in box-shadow utilities. The transition-[width] syntax works cleanly from v3.4+. The examples in this article were tested against v4.0.2, which also adds the motion-safe: variant support for the shimmer animation. If you're on v3.x, the arbitrary shadow approach still works — just double-check that your JIT mode is enabled.
They can, but you have to match the shadow colors to the card's background, not the page background. The neumorphic effect is tied to the surface color — shadows that look right on #e0e5ec will look wrong inside a #f5f0ff card. Expose the base color as a prop or CSS custom property so each instance can adapt its shadow to its container.