Stepper

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
  1. 계정
  2. 프로필
  3. 약관 동의
  4. 완료

Stepper

다단계 진행 인디케이터입니다. 완료/현재/예정 상태를 원+연결선으로 표시하고, onJump을 주면 통과한 단계로 클릭 점프할 수 있습니다.

진행 + 점프

이전/다음으로 단계를 옮기고, 통과한 단계는 인디케이터를 눌러 되돌아갑니다.

Source Code
<Stepper
  steps={["계정", "프로필", "약관 동의", "완료"]}
  current={step}
  maxStep={maxStep}
  onJump={setStep}
/>
  1. 계정
  2. 프로필
  3. 약관 동의
  4. 완료
Live Preview

표시 전용

onJump 없이 진행 상태만 보여줍니다.

Source Code
<Stepper steps={["주문", "결제", "배송", "완료"]} current={2} />
  1. 주문
  2. 결제
  3. 배송
  4. 완료
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Steppertypescript
'use client';

/**
 * Stepper 컴포넌트
 *
 * 다단계 진행 인디케이터. 완료/현재/예정 상태를 원 + 연결선으로 표시합니다.
 * - onJump을 주면 통과한(이전) 단계로 클릭 점프 — current 이후·maxStep 초과는 비활성.
 * - 표시 전용으로 쓰려면 onJump을 생략하면 됩니다.
 */

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

export interface StepperProps {
  steps: string[];
  /** 현재 단계 인덱스 (0-based) */
  current: number;
  /** 점프 허용 최고 단계 (기본 current) */
  maxStep?: number;
  /** 단계 클릭 시 호출 — 생략하면 표시 전용 */
  onJump?: (index: number) => void;
  className?: string;
}

export function Stepper({ steps, current, maxStep, onJump, className }: StepperProps) {
  const reachable = maxStep ?? current;
  return (
    <ol className={cn('flex items-center', className)}>
      {steps.map((label, i) => {
        const state = i < current ? 'done' : i === current ? 'current' : 'upcoming';
        const clickable = !!onJump && i < current && i <= reachable;
        const last = i === steps.length - 1;
        return (
          <li key={`${label}-${i}`} className={cn('flex items-center', !last && 'flex-1')}>
            <div className="flex flex-col items-center gap-1.5">
              <button
                type="button"
                onClick={() => clickable && onJump?.(i)}
                disabled={!clickable}
                aria-current={state === 'current' ? 'step' : undefined}
                className={cn(
                  'flex h-9 w-9 items-center justify-center rounded-full border-2 text-sm font-bold transition-all duration-300',
                  clickable && 'cursor-pointer hover:scale-105',
                  state === 'done' && 'border-slate-900 bg-slate-900 text-white',
                  state === 'current' && 'border-slate-900 bg-white text-slate-900 ring-4 ring-slate-900/10',
                  state === 'upcoming' && 'border-slate-200 bg-white text-slate-300',
                )}
              >
                {state === 'done' ? (
                  <svg className="h-4 w-4" viewBox="0 0 20 20" fill="none" aria-hidden>
                    <path d="M5 10.5l3.2 3.2L15 7" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                ) : (
                  i + 1
                )}
              </button>
              <span
                className={cn(
                  'whitespace-nowrap text-[11px] font-semibold transition-colors',
                  state === 'upcoming' ? 'text-slate-300' : 'text-slate-600',
                )}
              >
                {label}
              </span>
            </div>
            {!last && (
              <div className="mx-1 -mt-5 h-0.5 flex-1 overflow-hidden rounded-full bg-slate-200">
                <div
                  className="h-full rounded-full bg-slate-900 transition-all duration-500"
                  style={{ width: i < current ? '100%' : '0%' }}
                />
              </div>
            )}
          </li>
        );
      })}
    </ol>
  );
}