Neumorphism Step Indicator: Progress UI in Soft Design
Build a neumorphism step indicator with soft shadows and tactile feedback. React + Tailwind implementation with accessible state management and smooth transitions.
Why Neumorphism and Step Indicators Are a Natural Fit
Honestly, neumorphism gets dismissed too fast by people who slapped it on a dark theme and called it broken. The style was always about light surfaces, tactile depth, and the illusion that UI elements are physically part of the background. Step indicators — those little numbered progress trackers you see in checkouts, onboarding flows, multi-step forms — are exactly the kind of UI that benefits from that tactile quality.
A flat step indicator tells you where you are. A neumorphic one makes you *feel* where you are. Pressed inset on the active step, raised extruded on completed ones, and a flush neutral state for upcoming steps. That three-state depth system maps perfectly onto the three states a step can be in.
If you haven't read up on what neumorphism actually is yet, start there. This article assumes you understand the dual-shadow technique and are ready to apply it to a real, stateful component. We're going to build something you can actually ship.
The Shadow System Behind Neumorphic Steps
Neumorphism lives and dies on one value: the base background color. Every shadow you cast — light from the top-left, dark from the bottom-right — has to derive from that base. For a #e0e5ec surface, the light shadow is roughly rgba(255,255,255,0.8) and the dark shadow is around rgba(163,177,198,0.6). These aren't random picks. They're mathematically offset from the surface color.
Step states need three distinct shadow profiles. The *upcoming* state uses the standard extruded neumorphic double shadow: light top-left, dark bottom-right, both at maybe 6px offset with 12px blur. The *active* state flips to inset shadows — it looks pressed, like a physical button you're currently holding down. The *completed* state can stay extruded but with a tinted shadow pair that signals success, often a subtle green-tinted dark shadow.
One thing people get wrong: don't just invert the shadow for the active state and call it done. You need to reduce the spread slightly too. An inset shadow with the same spread as the extruded one looks muddy. Drop from box-shadow: 6px 6px 12px to box-shadow: inset 4px 4px 8px and it snaps into place. This is the kind of detail that separates a convincing soft-UI from a CSS experiment.
Building the React Component Structure
Here's the component skeleton. We're using TypeScript, Tailwind v4.0.2 for layout utilities, and raw CSS custom properties for the shadow values — because Tailwind's shadow utilities don't give us fine enough control for true neumorphism.
type StepStatus = 'upcoming' | 'active' | 'completed';
interface Step {
id: number;
label: string;
status: StepStatus;
}
const shadowMap: Record<StepStatus, string> = {
upcoming:
'6px 6px 12px rgba(163,177,198,0.6), -6px -6px 12px rgba(255,255,255,0.8)',
active:
'inset 4px 4px 8px rgba(163,177,198,0.6), inset -4px -4px 8px rgba(255,255,255,0.8)',
completed:
'6px 6px 12px rgba(120,160,140,0.35), -6px -6px 12px rgba(255,255,255,0.8)',
};
function StepDot({ step }: { step: Step }) {
return (
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{
background: '#e0e5ec',
boxShadow: shadowMap[step.status],
color: step.status === 'active' ? '#4a6fa5' : step.status === 'completed' ? '#3a7d5a' : '#9ca3af',
transition: 'box-shadow 0.3s ease, color 0.3s ease',
}}
>
{step.status === 'completed' ? '✓' : step.id}
</div>
);
}
export function NeumorphicStepIndicator({ steps }: { steps: Step[] }) {
return (
<div className="flex items-center gap-0">
{steps.map((step, i) => (
<>
<div key={step.id} className="flex flex-col items-center gap-2">
<StepDot step={step} />
<span className="text-xs text-gray-500 whitespace-nowrap">{step.label}</span>
</div>
{i < steps.length - 1 && (
<div
className="flex-1 h-px mx-2"
style={{
background:
step.status === 'completed'
? 'rgba(58,125,90,0.4)'
: 'rgba(163,177,198,0.4)',
minWidth: '32px',
}}
/>
)}
</>
))}
</div>
);
}The shadowMap object is doing the heavy lifting here. You swap box shadows based on status, and the transition property gives you a smooth morph between states. That 0.3s ease is fast enough to feel responsive without looking janky. Don't go below 0.2s or it won't register as intentional animation.
Managing Step State Without Overcomplicating It
You don't need Redux or Zustand for this. A single useState holding the current active step index is all you need for most use cases. The status derivation is pure logic: if stepIndex < activeIndex it's completed, if it equals activeIndex it's active, otherwise it's upcoming.
export function useStepIndicator(totalSteps: number, initial = 0) {
const [activeIndex, setActiveIndex] = React.useState(initial);
const steps = Array.from({ length: totalSteps }, (_, i) => ({
id: i + 1,
status:
i < activeIndex
? ('completed' as const)
: i === activeIndex
? ('active' as const)
: ('upcoming' as const),
}));
const next = () => setActiveIndex((prev) => Math.min(prev + 1, totalSteps - 1));
const prev = () => setActiveIndex((prev) => Math.max(prev - 1, 0));
const goTo = (index: number) => setActiveIndex(Math.max(0, Math.min(index, totalSteps - 1)));
return { steps, activeIndex, next, prev, goTo };
}Keep the hook pure. Don't put your form validation logic or API calls in here. This hook owns only one thing: which step is current. Everything else belongs elsewhere. This is what keeps the component reusable across different flows — onboarding, checkout, setup wizard, whatever.
If you need to prevent backward navigation (say, once a payment step is complete), just conditionally disable the prev function or remove the back button. Don't add a locked prop to the hook itself. That's a view concern, not a state concern.
Accessibility Considerations for Neumorphic Progress UI
Here's something a lot of neumorphism tutorials skip entirely: soft UI has a contrast problem. That light gray embossed button on a light gray background looks great on a calibrated display at 100% brightness. It looks nearly invisible to someone with low vision or on a mediocre laptop screen.
For step indicators specifically, don't rely on shadow depth alone to communicate state. Add aria-current="step" to the active step dot's container. Use aria-label on each dot with something like "Step 2 of 4: Shipping — active". And make sure the text color contrast ratio for your step labels clears WCAG AA — at minimum 4.5:1 for small text.
What about color-only state cues? That completed step with the green-tinted shadow? Add the checkmark text too, as we did in the component above. Never let color be the only signal. It's not a nice-to-have. It's the difference between a component that works for everyone and one that works for most people on a good day.
Neumorphism vs Glassmorphism for Step Indicators: Which Holds Up
It's worth asking: why not use glassmorphism here instead? If you've already read the glassmorphism vs neumorphism comparison, you'll know the short answer is context. Glassmorphism needs a rich background — gradients, images, color — to justify the frosted blur effect. Neumorphism works on plain surfaces, which is exactly what most app shells look like.
Step indicators usually appear inside dashboards, modals, or sidebars with neutral backgrounds. Neumorphism fits that environment naturally. Glassmorphism step indicators look odd unless you've designed your entire app around colorful backgrounds, which most SaaS tools haven't.
That said, you can absolutely mix. A glassmorphic card container with a neumorphic step indicator inside it isn't wrong — it's just a deliberate layering of depth systems. Just don't mix the shadow techniques on the same element. Pick one per component and stick to it. Trying to do backdrop-filter: blur() and dual-shadow neumorphism on the same div is a mess.
Theming: Dark Mode and Dynamic Color Surfaces
Dark-mode neumorphism is genuinely tricky. The style depends on light and shadow diverging from a mid-gray base. Go too dark and there's no room for the dark shadow; go too light and you've lost the aesthetic. A base around #1e2a38 with a light shadow of rgba(40,56,76,0.9) and a dark shadow of rgba(10,16,24,0.8) works reasonably well.
The cleanest way to handle this in React is CSS custom properties on :root with a [data-theme='dark'] override. Your shadow map becomes var(--neu-shadow-extruded) and var(--neu-shadow-inset). Then your theme toggle component just swaps the data-theme attribute and everything repaints automatically.
:root {
--neu-bg: #e0e5ec;
--neu-shadow-light: rgba(255, 255, 255, 0.8);
--neu-shadow-dark: rgba(163, 177, 198, 0.6);
--neu-extruded: 6px 6px 12px var(--neu-shadow-dark),
-6px -6px 12px var(--neu-shadow-light);
--neu-inset: inset 4px 4px 8px var(--neu-shadow-dark),
inset -4px -4px 8px var(--neu-shadow-light);
}
[data-theme='dark'] {
--neu-bg: #1e2a38;
--neu-shadow-light: rgba(40, 56, 76, 0.9);
--neu-shadow-dark: rgba(10, 16, 24, 0.8);
}This approach also makes it trivial to support brand color surfaces. Want neumorphism on a blue background? Change --neu-bg to #d0e4f7 and recalculate your shadow colors to be a lighter and darker tint of that blue. The formula stays the same; only the hue shifts.
Putting It All Together in an Empire UI Component
Empire UI ships this step indicator as part of its neumorphism component set, covering all 40 visual styles. The component accepts a steps array, a currentStep index, an optional onStepClick handler for clickable navigation, and a theme prop that maps to the CSS variable system described above.
If you want to explore how the style compares across design systems, the best free glassmorphism components article covers similar multi-step UI patterns in a different aesthetic. It's useful for understanding which style to reach for before you commit to a direction.
One last thing: test your step indicator at different viewport widths early. The horizontal layout with connecting lines works great on desktop. On mobile you'll typically want to either condense to a dot-only row (hide labels), or switch to a vertical stepper. The component we built here uses Tailwind's flex system so swapping flex-row to flex-col and adjusting the connector line from horizontal to vertical is about a 10-minute change. Don't leave that until the end.
FAQ
Yes, but you need to recalculate your shadow colors from a dark base. A base around #1e2a38 works well. Use CSS custom properties so you can swap light and dark shadow values via a data-theme attribute without touching component code.
On desktop use a horizontal div with height: 1px and flex-1 to fill the space between dots. On mobile, either hide labels and compress the dots to a tight row, or switch the entire layout to flex-col and replace the connector with a vertical line using width: 1px and flex-1 height.
Add aria-current="step" to the active step container, aria-label with full context (e.g., "Step 2 of 4: Shipping — active") to each dot, and role="list" on the wrapper with role="listitem" on each step. Never rely on color or shadow depth alone to communicate state.
You probably used the same offset and spread as your extruded shadow. Inset shadows need smaller values — drop from 6px offset / 12px blur to around 4px offset / 8px blur. The inset effect reads as pressed without the extra spread.
Yes. Add transition: box-shadow 0.3s ease, color 0.3s ease to the step dot element. CSS transitions work on box-shadow including the inset/extruded switch. Stay between 0.2s and 0.4s — faster feels broken, slower feels sluggish.
It works if you're intentional about it. Don't apply both techniques to the same element. A glassmorphic card containing neumorphic step dots is fine — each element uses one shadow system. Mixing both on a single div produces visual noise with no clear depth hierarchy.