Skip to content
6

Stepper

Multi-step progress indicator with horizontal and vertical orientation, completed / active / inactive states, clickable steps and a compound API.

Example

Installation

npx shadcn@latest add https://hirael.com/r/stepper.json

Usage

import {
  Stepper,
  StepperItem,
  StepperTrigger,
  StepperIndicator,
  StepperSeparator,
  StepperTitle,
  StepperDescription,
} from "@/components/stepper"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<Stepper />

+ native element props
PropTypeDefault
value

The active step, 1-based.

numbernone
defaultValuenumber1
onValueChange((step: number) => void)none
orientationOrientation'horizontal'

<StepperItem />

+ native element props
PropTypeDefault
step*

This item's position, 1-based.

numbernone
completed

Force the completed state regardless of the active step.

booleannone
disabledbooleanfalse

<StepperTrigger />

+ native element props

No props of its own; forwards everything to the underlying element.

<StepperIndicator />

+ native element props

No props of its own; forwards everything to the underlying element.

<StepperSeparator />

+ native element props

No props of its own; forwards everything to the underlying element.

<StepperTitle />

+ native element props

No props of its own; forwards everything to the underlying element.

<StepperDescription />

+ native element props

No props of its own; forwards everything to the underlying element.

Component source

'use client';

import * as React from 'react';
import { Check } from 'lucide-react';

import { cn } from '@/lib/utils';

type Orientation = 'horizontal' | 'vertical';
type StepState = 'active' | 'completed' | 'inactive';

interface StepperCtx {
  value: number;
  setValue: (step: number) => void;
  orientation: Orientation;
}

const StepperContext = React.createContext<StepperCtx | null>(null);

const useStepper = () => {
  const ctx = React.useContext(StepperContext);
  if (!ctx) {
    throw new Error('Stepper compound parts must be used inside <Stepper>');
  }
  return ctx;
};

interface StepperItemCtx {
  step: number;
  state: StepState;
  disabled: boolean;
}

const StepperItemContext = React.createContext<StepperItemCtx | null>(null);

const useStepperItem = () => {
  const ctx = React.useContext(StepperItemContext);
  if (!ctx) {
    throw new Error('Stepper item parts must be used inside <StepperItem>');
  }
  return ctx;
};

export interface StepperProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
  /** The active step, 1-based. */
  value?: number;
  defaultValue?: number;
  onValueChange?: (step: number) => void;
  orientation?: Orientation;
}

const Stepper = ({
  value: valueProp,
  defaultValue = 1,
  onValueChange,
  orientation = 'horizontal',
  className,
  ...props
}: StepperProps) => {
  const [internal, setInternal] = React.useState(defaultValue);
  const value = valueProp ?? internal;

  const setValue = React.useCallback(
    (step: number) => {
      if (valueProp === undefined) setInternal(step);
      onValueChange?.(step);
    },
    [valueProp, onValueChange],
  );

  const ctx = React.useMemo<StepperCtx>(() => ({ value, setValue, orientation }), [value, setValue, orientation]);

  return (
    <StepperContext.Provider value={ctx}>
      <div
        data-slot="stepper"
        data-orientation={orientation}
        className={cn(
          'group/stepper flex',
          orientation === 'horizontal' ? 'w-full items-center' : 'flex-col',
          className,
        )}
        {...props}
      />
    </StepperContext.Provider>
  );
};

export interface StepperItemProps extends React.ComponentProps<'div'> {
  /** This item's position, 1-based. */
  step: number;
  /** Force the completed state regardless of the active step. */
  completed?: boolean;
  disabled?: boolean;
}

const StepperItem = ({ step, completed, disabled = false, className, ...props }: StepperItemProps) => {
  const { value, orientation } = useStepper();

  const state: StepState = completed || step < value ? 'completed' : step === value ? 'active' : 'inactive';

  const ctx = React.useMemo<StepperItemCtx>(() => ({ step, state, disabled }), [step, state, disabled]);

  return (
    <StepperItemContext.Provider value={ctx}>
      <div
        data-slot="stepper-item"
        data-state={state}
        className={cn(
          'group/step flex',
          orientation === 'horizontal' ? 'items-center not-last:flex-1' : 'relative items-start gap-3 pb-8 last:pb-0',
          className,
        )}
        {...props}
      />
    </StepperItemContext.Provider>
  );
};

const StepperTrigger = ({ className, ...props }: React.ComponentProps<'button'>) => {
  const { setValue } = useStepper();
  const { step, state, disabled } = useStepperItem();

  return (
    <button
      type="button"
      data-slot="stepper-trigger"
      aria-current={state === 'active' ? 'step' : undefined}
      disabled={disabled}
      onClick={() => setValue(step)}
      className={cn(
        'flex items-center gap-3 rounded-md text-start outline-none transition-opacity',
        'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
        'disabled:cursor-not-allowed disabled:opacity-50',
        className,
      )}
      {...props}
    />
  );
};

const StepperIndicator = ({ className, children, ...props }: React.ComponentProps<'span'>) => {
  const { step, state } = useStepperItem();

  return (
    <span
      data-slot="stepper-indicator"
      data-state={state}
      aria-hidden
      className={cn(
        'inline-flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-medium tabular-nums transition-colors',
        state === 'completed' && 'border-primary bg-primary text-primary-foreground',
        state === 'active' && 'border-primary bg-primary/10 text-foreground',
        state === 'inactive' && 'border-border bg-card text-muted-foreground',
        className,
      )}
      {...props}
    >
      {children ?? (state === 'completed' ? <Check className="size-4" strokeWidth={2.5} /> : step)}
    </span>
  );
};

const StepperSeparator = ({ className, ...props }: React.ComponentProps<'div'>) => {
  return (
    <div
      data-slot="stepper-separator"
      aria-hidden
      className={cn(
        'bg-border transition-colors group-data-[state=completed]/step:bg-primary',
        'group-data-[orientation=horizontal]/stepper:mx-2 group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=horizontal]/stepper:flex-1',
        'group-data-[orientation=vertical]/stepper:absolute group-data-[orientation=vertical]/stepper:bottom-1 group-data-[orientation=vertical]/stepper:start-4 group-data-[orientation=vertical]/stepper:top-9 group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=vertical]/stepper:-translate-x-1/2 rtl:group-data-[orientation=vertical]/stepper:translate-x-1/2',
        className,
      )}
      {...props}
    />
  );
};

const StepperTitle = ({ className, ...props }: React.ComponentProps<'span'>) => {
  const { state } = useStepperItem();
  return (
    <span
      data-slot="stepper-title"
      className={cn(
        'block text-sm font-medium leading-tight transition-colors',
        state === 'inactive' ? 'text-muted-foreground' : 'text-foreground',
        className,
      )}
      {...props}
    />
  );
};

const StepperDescription = ({ className, ...props }: React.ComponentProps<'span'>) => {
  return (
    <span
      data-slot="stepper-description"
      className={cn('mt-0.5 block text-xs text-muted-foreground', className)}
      {...props}
    />
  );
};

export { Stepper, StepperItem, StepperTrigger, StepperIndicator, StepperSeparator, StepperTitle, StepperDescription };

Dependencies

npm

lucide-react