Progress
Accessible capsule progress bar with labeled percentage states.
components/ui/progress.tsx
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
label?: React.ReactNode
description?: React.ReactNode
showValue?: boolean
showPin?: boolean
striped?: boolean
animated?: boolean
indeterminate?: boolean
segments?: number
size?: 'sm' | 'md' | 'lg' | 'xl'
variant?: 'yellow' | 'orange' | 'mint' | 'violet' | 'pink' | 'sky' | 'lime' | 'gradient' | 'sunset' | 'neon'
shape?: 'pill' | 'rounded'
}
const fills = {
yellow: 'bg-rk-primary text-rk-ink',
orange: 'bg-rk-secondary text-rk-ink',
mint: 'bg-rk-success text-rk-ink',
violet: 'bg-rk-accent text-rk-ink',
pink: 'bg-rk-pink text-rk-ink',
sky: 'bg-rk-sky text-rk-ink',
lime: 'bg-[#A3E635] text-rk-ink',
gradient: 'bg-gradient-to-r from-[#FB923C] via-[#F87171] to-[#DC2626] text-white',
sunset: 'bg-gradient-to-r from-rk-primary via-rk-secondary to-[#F87171] text-rk-ink',
neon: 'bg-gradient-to-r from-[#A3E635] via-[#34D399] to-[#38BDF8] text-rk-ink',
}
const sizeTrackClasses = {
sm: 'h-5',
md: 'h-7',
lg: 'h-9',
xl: 'h-11',
}
export function Progress({
value = 0,
max = 100,
label,
description,
showValue = false,
showPin = false,
striped = false,
animated = false,
indeterminate = false,
segments,
size = 'md',
variant = 'yellow',
shape = 'pill',
className,
...props
}: ProgressProps) {
const safeMax = max > 0 ? max : 100
const percentage = indeterminate ? 100 : Math.min(100, Math.max(0, (value / safeMax) * 100))
const isStriped = striped || indeterminate
const isAnimated = animated || indeterminate
return (
<div className={cn('w-full space-y-2.5 font-sans', className)} {...props}>
{/* Header with Label, Subtitle & Percentage Badge */}
{(label || description || showValue) && (
<div className="flex items-center justify-between gap-3">
<div>
{label && (
<span className="font-display text-sm font-extrabold text-rk-ink tracking-tight">
{label}
</span>
)}
{description && (
<p className="text-xs font-semibold text-rk-ink/60 leading-none mt-0.5">
{description}
</p>
)}
</div>
{showValue && !indeterminate && (
<span className="inline-flex items-center justify-center rounded-full border-2 border-rk-ink bg-rk-primary px-2.5 py-0.5 font-mono text-xs font-black text-rk-ink rk-shadow-xs">
{Math.round(percentage)}%
</span>
)}
</div>
)}
{/* Progress Track Container */}
<div className="relative pt-6">
{/* Floating Percentage Pin Tag */}
{showPin && !indeterminate && (
<div
className="absolute top-0 -translate-y-1.5 transition-all duration-500 ease-out pointer-events-none z-10"
style={{ left: `calc(${percentage}% - 18px)` }}
>
<div className="relative rounded-md border-2 border-rk-ink bg-rk-ink px-2 py-0.5 font-mono text-[11px] font-black text-rk-primary rk-shadow-xs">
{Math.round(percentage)}%
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 h-2 w-2 rotate-45 border-b-2 border-r-2 border-rk-ink bg-rk-ink" />
</div>
</div>
)}
<div
aria-label={typeof label === 'string' ? label : 'Progress'}
aria-valuemax={safeMax}
aria-valuemin={0}
aria-valuenow={indeterminate ? undefined : value}
aria-valuetext={indeterminate ? 'indeterminate' : `${Math.round(percentage)}%`}
role="progressbar"
className={cn(
'relative w-full overflow-hidden bg-white p-1 rk-border rk-shadow-sm',
shape === 'pill' ? 'rounded-full' : 'rounded-xl',
sizeTrackClasses[size]
)}
>
{/* Track Fill */}
{indeterminate ? (
<div
className={cn(
'absolute top-1 bottom-1 transition-all duration-300',
shape === 'pill' ? 'rounded-full' : 'rounded-lg',
fills[variant],
'bg-[linear-gradient(45deg,rgba(0,0,0,0.18)_25%,transparent_25%,transparent_50%,rgba(0,0,0,0.18)_50%,rgba(0,0,0,0.18)_75%,transparent_75%,transparent)] bg-[length:1rem_1rem]',
'animate-[progress-indeterminate_1.8s_ease-in-out_infinite]'
)}
/>
) : (
<div
className={cn(
'relative h-full transition-all duration-500 ease-out',
shape === 'pill' ? 'rounded-full' : 'rounded-lg',
fills[variant],
striped &&
'bg-[linear-gradient(45deg,rgba(0,0,0,0.18)_25%,transparent_25%,transparent_50%,rgba(0,0,0,0.18)_50%,rgba(0,0,0,0.18)_75%,transparent_75%,transparent)] bg-[length:1.25rem_1.25rem]',
striped && animated && 'animate-[progress-stripes_0.8s_linear_infinite]'
)}
style={{ width: `${percentage}%` }}
>
{/* Inner Text for Large Size */}
{size === 'xl' && !showPin && showValue && (
<span className="absolute inset-0 flex items-center justify-end pr-3 font-mono text-xs font-black text-rk-ink">
{Math.round(percentage)}%
</span>
)}
</div>
)}
{/* Optional Segment Dividers */}
{segments && segments > 1 && (
<div className="absolute inset-0 flex w-full pointer-events-none px-1">
{Array.from({ length: segments - 1 }).map((_, i) => (
<div
key={i}
className="h-full border-r-2 border-rk-ink/40"
style={{ width: `${100 / segments}%` }}
/>
))}
</div>
)}
</div>
</div>
</div>
)
}
- Where it goes
- stats
- Interaction
- animated-counter
DependenciesBundle weight · light
clsxtailwind-merge