Progress Indicator
The Progress Indicator component provides both linear and circular progress displays with support for determinate and indeterminate states, animated fills, percentage labels, and multiple label positions. It is ideal for showing loading states, task completion, or multi-step workflows.
Interactive Demo
- Preview
- Code
64%
import { ProgressIndicator } from './components/ui/progress-indicator';
function Example() {
return (
<ProgressIndicator
type="linear"
value={64}
indeterminate={false}
showPercentage={true}
animationVariant="smooth"
labelPosition="bottom"
/>
);
}
Installation
- CLI
- Manual
ignix add component progress-indicator
import * as React from "react";
import { motion, type Transition } from "framer-motion";
import { cn } from "../../../utils/cn";
export type ProgressIndicatorType = "linear" | "circular";
export type ProgressAnimationVariant = "none" | "smooth" | "spring" | "bounce" | "snappy";
export type LinearLabelPosition =
| "top"
| "bottom"
| "inside-left"
| "inside-right"
| "inside-center"
| "outside-left"
| "outside-right";
export type CircularLabelPosition =
| "inside-center"
| "outside-top"
| "outside-bottom"
| "outside-left"
| "outside-right";
type ProgressTransition = Transition;
export type ProgressIndicatorProps = {
type?: ProgressIndicatorType;
value?: number;
indeterminate?: boolean;
showPercentage?: boolean;
labelPosition?: LinearLabelPosition | CircularLabelPosition;
ariaLabel?: string;
animationVariant?: ProgressAnimationVariant;
formatPercentage?: (pct: number) => string;
trackClassName?: string;
fillClassName?: string;
className?: string;
linearHeight?: number;
size?: number;
strokeWidth?: number;
};
const clamp01To100 = (n: number): number => Math.min(100, Math.max(0, n));
const transitionByVariant: Record<ProgressAnimationVariant, ProgressTransition> = {
none: { duration: 0 },
smooth: { type: "tween", duration: 0.35, ease: "easeOut" },
snappy: { type: "tween", duration: 0.2, ease: "easeOut" },
spring: { type: "spring", stiffness: 220, damping: 26 },
bounce: { type: "spring", stiffness: 320, damping: 18 },
};
type LinearProgressProps = Required<
Pick<
ProgressIndicatorProps,
| "value"
| "indeterminate"
| "showPercentage"
| "animationVariant"
| "formatPercentage"
| "trackClassName"
| "fillClassName"
| "linearHeight"
>
> & {
ariaLabel: string;
labelPosition?: LinearLabelPosition;
className?: string;
};
const LinearProgress = React.memo(function LinearProgress({
value,
indeterminate,
showPercentage,
animationVariant,
formatPercentage,
trackClassName,
fillClassName,
linearHeight,
ariaLabel,
labelPosition = "bottom",
className,
}: LinearProgressProps) {
const pct = React.useMemo(() => clamp01To100(value), [value]);
const label = React.useMemo(() => formatPercentage(pct), [formatPercentage, pct]);
const containerStyle = React.useMemo<React.CSSProperties>(
() => ({ height: linearHeight }),
[linearHeight]
);
const renderLabel = React.useCallback(() => {
if (!showPercentage || indeterminate) return null;
const labelClasses = "text-xs font-medium text-muted-foreground";
if (labelPosition === "inside-left") {
return (
<div className="absolute left-2 top-1/2 -translate-y-1/2 z-10 text-white drop-shadow-sm">
{label}
</div>
);
}
if (labelPosition === "inside-right") {
return (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10 text-white drop-shadow-sm">
{label}
</div>
);
}
if (labelPosition === "inside-center") {
return (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 text-white drop-shadow-sm">
{label}
</div>
);
}
if (labelPosition === "top") {
return <div className={cn("mb-2", labelClasses)}>{label}</div>;
}
if (labelPosition === "outside-left" || labelPosition === "outside-right") {
return <span className={labelClasses}>{label}</span>;
}
return <div className={cn("mt-2", labelClasses)}>{label}</div>;
}, [showPercentage, indeterminate, labelPosition, label]);
return (
<div className={cn("w-full", className)}>
{labelPosition === "top" && renderLabel()}
<div className={cn("flex items-center gap-2")}>
{labelPosition === "outside-left" && renderLabel()}
<div
className={cn("relative w-full overflow-hidden rounded-full", trackClassName)}
style={containerStyle}
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={indeterminate ? undefined : 0}
aria-valuemax={indeterminate ? undefined : 100}
aria-valuenow={indeterminate ? undefined : pct}
>
{!indeterminate ? (
<motion.div
className={cn("h-full rounded-full", fillClassName)}
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={transitionByVariant[animationVariant]}
style={{ willChange: "width" } as React.CSSProperties}
/>
) : (
<motion.div
className={cn("absolute inset-y-0 left-0 rounded-full", fillClassName)}
style={{ width: "40%", willChange: "transform" } as React.CSSProperties}
animate={{ x: ["-60%", "140%"] }}
transition={{
repeat: Infinity,
duration: 1.1,
ease: "easeInOut",
}}
/>
)}
{(labelPosition === "inside-left" ||
labelPosition === "inside-right" ||
labelPosition === "inside-center") &&
renderLabel()}
</div>
{labelPosition === "outside-right" && renderLabel()}
</div>
{labelPosition === "bottom" && renderLabel()}
</div>
);
});
type CircularProgressProps = Required<
Pick<
ProgressIndicatorProps,
| "value"
| "indeterminate"
| "showPercentage"
| "animationVariant"
| "formatPercentage"
| "trackClassName"
| "fillClassName"
| "size"
| "strokeWidth"
>
> & {
ariaLabel: string;
labelPosition?: CircularLabelPosition;
className?: string;
};
const CircularProgress = React.memo(function CircularProgress({
value,
indeterminate,
showPercentage,
animationVariant,
formatPercentage,
trackClassName,
fillClassName,
size,
strokeWidth,
ariaLabel,
labelPosition = "inside-center",
className,
}: CircularProgressProps) {
const pct = React.useMemo(() => clamp01To100(value), [value]);
const label = React.useMemo(() => formatPercentage(pct), [formatPercentage, pct]);
const radius = React.useMemo(() => (size - strokeWidth) / 2, [size, strokeWidth]);
const circumference = React.useMemo(() => 2 * Math.PI * radius, [radius]);
const dashOffset = React.useMemo(
() => circumference * (1 - pct / 100),
[circumference, pct]
);
const svgBox = React.useMemo<React.CSSProperties>(
() => ({ width: size, height: size }),
[size]
);
const center = React.useMemo<number>(() => size / 2, [size]);
const labelClasses = "text-xs font-medium text-muted-foreground whitespace-nowrap";
return (
<div className={cn("inline-flex flex-col items-center justify-center", className)}>
{labelPosition === "outside-top" && showPercentage && !indeterminate && (
<span className={cn(labelClasses, "mb-2")}>{label}</span>
)}
<div className="flex items-center gap-2">
{labelPosition === "outside-left" && showPercentage && !indeterminate && (
<span className={labelClasses}>{label}</span>
)}
<div
className="relative inline-flex items-center justify-center"
style={svgBox}
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={indeterminate ? undefined : 0}
aria-valuemax={indeterminate ? undefined : 100}
aria-valuenow={indeterminate ? undefined : pct}
>
<motion.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="block"
animate={indeterminate ? { rotate: 360 } : undefined}
transition={
indeterminate
? ({ repeat: Infinity, ease: "linear", duration: 1 } as ProgressTransition)
: undefined
}
style={
({ willChange: indeterminate ? "transform" : undefined } as React.CSSProperties)
}
>
<circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
className={cn("fill-transparent", trackClassName)}
stroke="currentColor"
opacity={0.25}
/>
{!indeterminate ? (
<motion.circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
className={cn("fill-transparent", fillClassName)}
stroke="currentColor"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: dashOffset }}
transition={transitionByVariant[animationVariant]}
style={
{
transform: `rotate(-90deg)`,
transformOrigin: "50% 50%",
willChange: "stroke-dashoffset",
} as React.CSSProperties
}
/>
) : (
<motion.circle
cx={center}
cy={center}
r={radius}
strokeWidth={strokeWidth}
className={cn("fill-transparent", fillClassName)}
stroke="currentColor"
strokeLinecap="round"
strokeDasharray={`${circumference * 0.25} ${circumference}`}
animate={{ strokeDashoffset: [0, -circumference] }}
transition={{
repeat: Infinity,
duration: 1.2,
ease: "easeInOut",
}}
style={
{
transform: `rotate(-90deg)`,
transformOrigin: "50% 50%",
willChange: "stroke-dashoffset",
} as React.CSSProperties
}
/>
)}
</motion.svg>
{labelPosition === "inside-center" && showPercentage && !indeterminate && (
<div className="absolute inset-0 flex items-center justify-center">
<span className={labelClasses}>{label}</span>
</div>
)}
</div>
{labelPosition === "outside-right" && showPercentage && !indeterminate && (
<span className={labelClasses}>{label}</span>
)}
</div>
{labelPosition === "outside-bottom" && showPercentage && !indeterminate && (
<span className={cn(labelClasses, "mt-2")}>{label}</span>
)}
</div>
);
});
export const ProgressIndicator = React.memo(function ProgressIndicator({
type = "linear",
value = 0,
indeterminate = false,
showPercentage = false,
labelPosition,
ariaLabel,
animationVariant = "smooth",
formatPercentage,
trackClassName = "bg-slate-200 dark:bg-slate-800",
fillClassName = "bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500",
className,
linearHeight = 8,
size = 64,
strokeWidth = 6,
}: ProgressIndicatorProps) {
const defaultFormatter = React.useCallback((pct: number) => `${Math.round(pct)}%`, []);
const formatPct = formatPercentage ?? defaultFormatter;
const computedAriaLabel = React.useMemo(() => {
if (ariaLabel) return ariaLabel;
return indeterminate ? "Loading" : "Progress";
}, [ariaLabel, indeterminate]);
const defaultLabelPosition = React.useMemo(() => {
if (labelPosition) return labelPosition;
return type === "circular" ? "inside-center" : "bottom";
}, [labelPosition, type]);
if (type === "circular") {
return (
<CircularProgress
value={value}
indeterminate={indeterminate}
showPercentage={showPercentage}
animationVariant={animationVariant}
formatPercentage={formatPct}
trackClassName={trackClassName}
fillClassName={fillClassName}
size={size}
strokeWidth={strokeWidth}
ariaLabel={computedAriaLabel}
labelPosition={defaultLabelPosition as CircularLabelPosition}
className={className}
/>
);
}
return (
<LinearProgress
value={value}
indeterminate={indeterminate}
showPercentage={showPercentage}
animationVariant={animationVariant}
formatPercentage={formatPct}
trackClassName={trackClassName}
fillClassName={fillClassName}
linearHeight={linearHeight}
ariaLabel={computedAriaLabel}
labelPosition={defaultLabelPosition as LinearLabelPosition}
className={className}
/>
);
});
Usage
Import the component:
import { ProgressIndicator } from './components/ui/progress-indicator';
Linear progress
function LinearProgressExample() {
return (
<ProgressIndicator
type="linear"
value={48}
showPercentage
animationVariant="smooth"
labelPosition="outside-right"
className="w-full max-w-md"
/>
);
}
Circular progress
function CircularProgressExample() {
return (
<ProgressIndicator
type="circular"
value={72}
showPercentage
animationVariant="spring"
labelPosition="inside-center"
size={80}
strokeWidth={8}
/>
);
}
Indeterminate state
function IndeterminateExample() {
return (
<ProgressIndicator
type="linear"
indeterminate
showPercentage={false}
animationVariant="smooth"
className="w-full max-w-md"
/>
);
}
Custom percentage formatting
function CustomLabelExample() {
const format = (pct: number) => `Completed ${Math.round(pct)}%`;
return (
<ProgressIndicator
type="linear"
value={33}
showPercentage
animationVariant="spring"
formatPercentage={format}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | "linear" | "circular" | "linear" | Determines whether the indicator is rendered as a linear bar or circular ring. |
value | number | 0 | Progress value from 0 to 100. Ignored when indeterminate is true. |
indeterminate | boolean | false | When true, shows an indeterminate animation with no fixed progress value. |
showPercentage | boolean | false | When true, displays a percentage label. |
labelPosition | LinearLabelPosition | CircularLabelPosition | bottom / inside-center | Position of the percentage label depending on type. |
ariaLabel | string | "Loading" / "Progress" | Accessible label for screen readers. |
animationVariant | "none" | "smooth" | "spring" | "bounce" | "snappy" | "smooth" | Controls the easing style used when animating determinate progress changes. |
formatPercentage | (pct: number) => string | value => Math.round(value) + '%' | Custom formatter for the percentage label. |
trackClassName | string | "bg-slate-200 dark:bg-slate-800" | Tailwind classes applied to the track (background). |
fillClassName | string | gradient primary background | Tailwind classes applied to the filled portion (foreground). |
className | string | undefined | Additional classes applied to the root container. |
linearHeight | number | 8 | Height (in px) of the linear progress bar. |
size | number | 64 | Diameter (in px) of the circular progress indicator. |
strokeWidth | number | 6 | Stroke width (in px) of the circular progress ring. |
Label positions
Linear (LinearLabelPosition)
top– label above the barbottom– label below the barinside-left– label inside the bar, aligned leftinside-right– label inside the bar, aligned rightinside-center– label centered inside the baroutside-left– label to the left of the baroutside-right– label to the right of the bar
Circular (CircularLabelPosition)
inside-center– label centered inside the circleoutside-top– label above the circleoutside-bottom– label below the circleoutside-left– label to the left of the circleoutside-right– label to the right of the circle
Best Practices
- Use linear progress for long horizontal areas like forms or wizard steps.
- Use circular progress for compact loading indicators or dashboard metrics.
- Prefer
indeterminatemode when the total duration is unknown. - Use
formatPercentageto align labels with your product language (e.g."Syncing 45%"). - Place labels outside (
outside-left/outside-right) when precise values matter and you want maximum clarity.