diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.module.css b/src/design-system/primitives/ProgressBar/ProgressBar.module.css
new file mode 100644
index 0000000..09731dd
--- /dev/null
+++ b/src/design-system/primitives/ProgressBar/ProgressBar.module.css
@@ -0,0 +1,48 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+}
+
+.label {
+ font-family: var(--font-body);
+ font-size: 11px;
+ color: var(--text-secondary);
+ line-height: 1;
+}
+
+.track {
+ width: 100%;
+ background: var(--bg-inset);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.sm { height: 4px; }
+.md { height: 8px; }
+
+.fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+.primary { background: var(--amber); }
+.success { background: var(--success); }
+.warning { background: var(--warning); }
+.error { background: var(--error); }
+.running { background: var(--running); }
+
+/* Indeterminate shimmer */
+@keyframes shimmer {
+ 0% { left: -40%; width: 40%; }
+ 50% { left: 30%; width: 60%; }
+ 100% { left: 100%; width: 40%; }
+}
+
+.indeterminate {
+ position: relative;
+ width: 40% !important;
+ animation: shimmer 1.4s ease-in-out infinite;
+}
diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx b/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx
new file mode 100644
index 0000000..0335833
--- /dev/null
+++ b/src/design-system/primitives/ProgressBar/ProgressBar.test.tsx
@@ -0,0 +1,100 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { ProgressBar } from './ProgressBar'
+
+describe('ProgressBar', () => {
+ it('renders with default props', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toBeInTheDocument()
+ })
+
+ it('sets aria-valuenow to the clamped value', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toHaveAttribute('aria-valuenow', '42')
+ })
+
+ it('always sets aria-valuemin=0 and aria-valuemax=100', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toHaveAttribute('aria-valuemin', '0')
+ expect(bar).toHaveAttribute('aria-valuemax', '100')
+ })
+
+ it('clamps value above 100 to 100', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toHaveAttribute('aria-valuenow', '100')
+ })
+
+ it('clamps value below 0 to 0', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toHaveAttribute('aria-valuenow', '0')
+ })
+
+ it('omits aria-valuenow when indeterminate', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).not.toHaveAttribute('aria-valuenow')
+ })
+
+ it('renders label text above the bar', () => {
+ render()
+ expect(screen.getByText('Upload progress')).toBeInTheDocument()
+ })
+
+ it('does not render label element when label is omitted', () => {
+ render()
+ expect(screen.queryByText('Upload progress')).not.toBeInTheDocument()
+ })
+
+ it('applies variant class to fill', () => {
+ const { container } = render()
+ const fill = container.querySelector('.fill')
+ expect(fill).toHaveClass('success')
+ })
+
+ it('applies error variant class to fill', () => {
+ const { container } = render()
+ const fill = container.querySelector('.fill')
+ expect(fill).toHaveClass('error')
+ })
+
+ it('applies sm size class to track', () => {
+ const { container } = render()
+ const track = screen.getByRole('progressbar')
+ expect(track).toHaveClass('sm')
+ })
+
+ it('applies md size class to track by default', () => {
+ const { container } = render()
+ const track = screen.getByRole('progressbar')
+ expect(track).toHaveClass('md')
+ })
+
+ it('applies indeterminate class to fill when indeterminate', () => {
+ const { container } = render()
+ const fill = container.querySelector('.fill')
+ expect(fill).toHaveClass('indeterminate')
+ })
+
+ it('sets fill width style matching value', () => {
+ const { container } = render()
+ const fill = container.querySelector('.fill') as HTMLElement
+ expect(fill.style.width).toBe('75%')
+ })
+
+ it('does not set width style when indeterminate', () => {
+ const { container } = render()
+ const fill = container.querySelector('.fill') as HTMLElement
+ expect(fill.style.width).toBe('')
+ })
+
+ it('passes through className to the track', () => {
+ render()
+ const bar = screen.getByRole('progressbar')
+ expect(bar).toHaveClass('custom-class')
+ })
+})
diff --git a/src/design-system/primitives/ProgressBar/ProgressBar.tsx b/src/design-system/primitives/ProgressBar/ProgressBar.tsx
new file mode 100644
index 0000000..c2a6e9f
--- /dev/null
+++ b/src/design-system/primitives/ProgressBar/ProgressBar.tsx
@@ -0,0 +1,60 @@
+import styles from './ProgressBar.module.css'
+
+type ProgressBarVariant = 'primary' | 'success' | 'warning' | 'error' | 'running'
+type ProgressBarSize = 'sm' | 'md'
+
+interface ProgressBarProps {
+ value?: number
+ variant?: ProgressBarVariant
+ size?: ProgressBarSize
+ indeterminate?: boolean
+ label?: string
+ className?: string
+}
+
+export function ProgressBar({
+ value = 0,
+ variant = 'primary',
+ size = 'md',
+ indeterminate = false,
+ label,
+ className,
+}: ProgressBarProps) {
+ const clampedValue = Math.min(100, Math.max(0, value))
+
+ const trackClasses = [
+ styles.track,
+ styles[size],
+ className ?? '',
+ ].filter(Boolean).join(' ')
+
+ const fillClasses = [
+ styles.fill,
+ styles[variant],
+ indeterminate ? styles.indeterminate : '',
+ ].filter(Boolean).join(' ')
+
+ const ariaProps = indeterminate
+ ? { 'aria-valuenow': undefined }
+ : { 'aria-valuenow': clampedValue }
+
+ return (
+
+ {label && (
+
{label}
+ )}
+
+
+ )
+}