Progress

Interactive UI Explorer

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

Live Preview
70%
45%
92%
75%
40%

Progress

진행률 — bar(가로 막대) 또는 ring(원형) 형태. 색상 토큰 5종(default · primary · success · warning · error) 지원.

Bar

가로 막대형. showLabel로 % 텍스트 노출.

Default 60%
<Progress variant="bar" value={60} showLabel />
60%
Live Preview
Success 90%
<Progress variant="bar" value={90} color="success" showLabel />
90%
Live Preview
Warning 35%
<Progress variant="bar" value={35} color="warning" showLabel />
35%
Live Preview
Indeterminate
<Progress variant="bar" value={0} indeterminate color="primary" />
Live Preview

Ring

원형. 사이즈는 box 픽셀로 자동 계산되며 라벨이 중앙에 들어감.

SM 40%
<Progress variant="ring" value={40} size="sm" showLabel />
40%
Live Preview
MD 75%
<Progress variant="ring" value={75} size="md" color="success" showLabel />
75%
Live Preview
LG 90%
<Progress variant="ring" value={90} size="lg" color="primary" showLabel />
90%
Live Preview
Indeterminate
<Progress variant="ring" value={0} indeterminate />
Live Preview

Interactive

state 연동. 슬라이더로 값을 바꿔 transition 동작을 확인.

Source Code
const [val, setVal] = useState(40);

<Progress variant="bar" value={val} showLabel />
40%
Live Preview
Implementation

제작 코드

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

Progresstypescript
'use client';

/**
 * Progress — 진행률 표시.
 *
 * - variant: bar (가로 막대) / ring (원형)
 * - color: default(slate) · primary(blue) · success(emerald) · warning(amber) · error(rose)
 * - value 0~100 (또는 max 지정 가능)
 * - showLabel로 % 텍스트 노출
 */

import { forwardRef, type HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';

export type ProgressVariant = 'bar' | 'ring';
export type ProgressColor = 'default' | 'primary' | 'success' | 'warning' | 'error';
export type ProgressSize = 'sm' | 'md' | 'lg';

export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
  /** 0 ~ max */
  value: number;
  max?: number;
  variant?: ProgressVariant;
  color?: ProgressColor;
  size?: ProgressSize;
  /** 라벨 노출 — bar는 우측, ring은 중앙 */
  showLabel?: boolean;
  /** indeterminate 애니메이션 (value 무시) */
  indeterminate?: boolean;
}

const colorBar: Record<ProgressColor, string> = {
  default: 'bg-slate-900',
  primary: 'bg-blue-500',
  success: 'bg-emerald-500',
  warning: 'bg-amber-500',
  error: 'bg-rose-500',
};

const colorStroke: Record<ProgressColor, string> = {
  default: 'stroke-slate-900',
  primary: 'stroke-blue-500',
  success: 'stroke-emerald-500',
  warning: 'stroke-amber-500',
  error: 'stroke-rose-500',
};

const barSize: Record<ProgressSize, string> = {
  sm: 'h-1.5',
  md: 'h-2',
  lg: 'h-3',
};

const ringSize: Record<ProgressSize, { box: number; stroke: number; text: string }> = {
  sm: { box: 36, stroke: 4, text: 'text-[10px]' },
  md: { box: 56, stroke: 5, text: 'text-xs' },
  lg: { box: 80, stroke: 6, text: 'text-sm' },
};

function clamp(v: number, min: number, max: number): number {
  return Math.max(min, Math.min(v, max));
}

export const Progress = forwardRef<HTMLDivElement, ProgressProps>(
  (
    {
      value,
      max = 100,
      variant = 'bar',
      color = 'default',
      size = 'md',
      showLabel = false,
      indeterminate = false,
      className,
      ...rest
    },
    ref,
  ) => {
    const pct = max > 0 ? clamp((value / max) * 100, 0, 100) : 0;
    const labelText = `${Math.round(pct)}%`;

    if (variant === 'ring') {
      const { box, stroke, text } = ringSize[size];
      const r = (box - stroke) / 2;
      const c = 2 * Math.PI * r;
      const offset = c - (pct / 100) * c;

      return (
        <div
          ref={ref}
          role="progressbar"
          aria-valuemin={0}
          aria-valuemax={max}
          aria-valuenow={indeterminate ? undefined : value}
          className={cn('relative inline-flex items-center justify-center', className)}
          style={{ width: box, height: box }}
          {...rest}
        >
          <svg width={box} height={box} className={indeterminate ? 'animate-spin' : undefined}>
            <circle
              cx={box / 2}
              cy={box / 2}
              r={r}
              fill="none"
              strokeWidth={stroke}
              className="stroke-slate-200"
            />
            <circle
              cx={box / 2}
              cy={box / 2}
              r={r}
              fill="none"
              strokeWidth={stroke}
              strokeLinecap="round"
              className={cn('transition-[stroke-dashoffset] duration-500 ease-out', colorStroke[color])}
              strokeDasharray={c}
              strokeDashoffset={indeterminate ? c * 0.75 : offset}
              transform={`rotate(-90 ${box / 2} ${box / 2})`}
            />
          </svg>
          {showLabel && !indeterminate && (
            <span className={cn('absolute font-bold tabular-nums text-slate-700', text)}>
              {labelText}
            </span>
          )}
        </div>
      );
    }

    // bar
    return (
      <div
        ref={ref}
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={max}
        aria-valuenow={indeterminate ? undefined : value}
        className={cn('flex w-full items-center gap-3', className)}
        {...rest}
      >
        <div className={cn('relative flex-1 overflow-hidden rounded-full bg-slate-200', barSize[size])}>
          {indeterminate ? (
            <div
              className={cn(
                'absolute inset-y-0 left-0 w-1/3 rounded-full',
                colorBar[color],
              )}
              style={{ animation: 'progress-indeterminate 1.4s ease-in-out infinite' }}
            />
          ) : (
            <div
              className={cn('h-full rounded-full transition-[width] duration-500 ease-out', colorBar[color])}
              style={{ width: `${pct}%` }}
            />
          )}
        </div>
        {showLabel && !indeterminate && (
          <span className="text-xs font-bold tabular-nums text-slate-600 shrink-0">
            {labelText}
          </span>
        )}
      </div>
    );
  },
);

Progress.displayName = 'Progress';