컴포넌트
진행률 표시
작업이 얼마나 진행됐는지 막대와 숫자로 보여 줘요.
- #arc
- #component
- #progress
- #bar
- #status
- #loading
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/progress.json파일
components/arc/progress/progress.module.csscomponents/arc/progress/progress.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
progress.module.cssprogress.module.cssprogress.module.csscss
.progress { width: 100%; color: var(--text-secondary); font-size: var(--text-sm); } .meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; line-height: var(--leading-body); } .label { position: relative; min-width: 0; font-weight: var(--label-weight); } .line { display: block; } .value { display: inline-flex; flex: none; align-items: center; gap: 4px; color: var(--text-muted); font-variant-numeric: tabular-nums; } .count { display: inline-grid; justify-items: end; } /* A hidden "100%" row reserves the widest count, so the check never shifts as the number gains a digit. */ .count::before { content: "100%"; height: 0; overflow: hidden; visibility: hidden; } .done { display: grid; place-items: center; color: var(--success); } .track { height: 7px; overflow: hidden; border: 1px solid var(--border); border-radius: 99px; background: var(--surface-muted); } .fill { display: block; width: 100%; height: 100%; border-radius: inherit; background: var(--accent); transition: background-color var(--duration-standard) var(--ease-standard); } .progress[data-complete] .fill { background: var(--success); transition-delay: 200ms; } @media (prefers-reduced-motion: reduce) { .fill, .progress[data-complete] .fill { transition: none; } }
progress.tsxprogress.tsxprogress.tsxtsx
"use client";
import { useEffect } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useTransform, type HTMLMotionProps, type TargetAndTransition, type Transition } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./progress.module.css";
import { UiIcon } from "../theme/ui-icon";
export interface ProgressProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
value?: number;
max?: number;
label?: string;
showValue?: boolean;
}
const exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
const textIn: TargetAndTransition = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const shown: TargetAndTransition = { opacity: 1, y: "0em", scale: 1, filter: "blur(0px)" };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Outgoing copies are hidden from assistive tech while they fade. */
function Swap(props: HTMLMotionProps<"span">) {
const present = useIsPresent();
return <motion.span {...props} aria-hidden={present ? props["aria-hidden"] : true} />;
}
export function Progress({ value = 0, max = 100, label, showValue = false, className, ...props }: ProgressProps) {
const reduce = useReducedMotion();
const safeMax = max > 0 ? max : 100;
const safeValue = Math.min(Math.max(value, 0), safeMax);
const percentage = Math.round((safeValue / safeMax) * 100);
const complete = percentage >= 100;
// One spring drives both the fill and the counted label, so the number always matches the bar.
const progress = useMotionValue(percentage);
// The fill slides in from the left instead of scaling, so its rounded end keeps its shape at every value.
const x = useTransform(progress, latest => `${Math.min(Math.max(latest, 0), 100) - 100}%`);
const counted = useTransform(progress, latest => `${Math.round(Math.min(Math.max(latest, 0), 100))}%`);
useEffect(() => {
if (reduce) { progress.jump(percentage); return; }
const controls = animate(progress, percentage, motionTokens.spring.smooth);
return () => controls.stop();
}, [percentage, progress, reduce]);
const enter: Transition = reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
const classes = [styles.progress, className].filter(Boolean).join(" ");
return <div {...props} className={classes} data-complete={complete ? "" : undefined} role="progressbar" aria-label={props["aria-label"] ?? label ?? "진행률"} aria-valuemin={0} aria-valuemax={safeMax} aria-valuenow={safeValue} aria-valuetext={`${percentage}%`}>
{(label || showValue) ? <div className={styles.meta}>
{label ? <span className={styles.label}><AnimatePresence mode="popLayout" initial={false}><Swap key={label} className={styles.line} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? fadeOut : textOut} transition={enter}>{label}</Swap></AnimatePresence></span> : <span />}
{showValue ? <span className={styles.value}>
{/* Completion lands as the fill arrives: a check settles in beside the final count. */}
<AnimatePresence initial={false}>{complete && <motion.span key="done" className={styles.done} initial={reduce ? { opacity: 0 } : iconIn} animate={shown} exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }} transition={reduce ? enter : { ...motionTokens.spring.snappy, delay: motionTokens.duration.standard }}><UiIcon iconRole="check" size={14} strokeWidth={1.75} aria-hidden="true" /></motion.span>}</AnimatePresence>
<motion.span className={styles.count}>{counted}</motion.span>
</span> : null}
</div> : null}
<div className={styles.track}><motion.span className={styles.fill} style={{ x }} /></div>
</div>;
}
export default Progress;