EmpireUI
Get Pro
← Blog7 min read#neumorphism#step-indicator#progress-ui

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.

Soft neumorphic UI design with step progress indicator on a light gray background showing embossed circular elements

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

Can I use neumorphic step indicators on dark backgrounds?

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.

How do I handle the connecting line between steps in a responsive layout?

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.

What ARIA attributes are needed for an accessible step indicator?

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.

Why does my neumorphic inset shadow look muddy on the active step?

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.

Can I animate between step states smoothly?

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.

Is it okay to mix neumorphism with other styles like glassmorphism in the same app?

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.

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

Read next

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