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%
60%
Live Preview
Success 90%
90%
Live Preview
Warning 35%
35%
Live Preview
Indeterminate
Live Preview
Ring
원형. 사이즈는 box 픽셀로 자동 계산되며 라벨이 중앙에 들어감.
SM 40%
40%
Live Preview
MD 75%
75%
Live Preview
LG 90%
90%
Live Preview
Indeterminate
Live Preview
Interactive
state 연동. 슬라이더로 값을 바꿔 transition 동작을 확인.
Source Code
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';