Progress
A linear or circular indicator showing task completion, with color that shifts by status.
npx shadcn@latest add @velaui/progresspnpm dlx velaui add @velaui/progressyarn dlx shadcn@latest add @velaui/progressbunx --bun shadcn@latest add @velaui/progressveni add progressInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/progresspnpm dlx velaui add @velaui/progressyarn dlx shadcn@latest add @velaui/progressbunx --bun shadcn@latest add @velaui/progressExamples
- Default
- Bold neutral
- Done
- Circle sizes
- Circle done
Usage
Props
Progress
| Prop | Type | Default |
|---|---|---|
doneLabel | string | "Done" |
showValue | boolean | true |
thickness | "thin" | "bold" | "thin" |
variant | ProgressVariant | "accent" |
Takes these and every prop of ProgressProps.
ProgressCircle
| Prop | Type | Default |
|---|---|---|
showValue | boolean | true |
size | "sm" | "md" | "lg" | "lg" |
variant | ProgressVariant | "accent" |
Takes these and every prop of ProgressCircleProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0- Installs with it
- 1 icon
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1- Installs with it
- 1 icon
Source
components/ui/progress.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { Check01 } from "@/components/icons/check-01";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Variant */
/* -------------------------------------------------------------------------- */
export type ProgressVariant = "accent" | "neutral" | "status";
function clampPercent(value: number | null | undefined) {
return Math.min(Math.max(value ?? 0, 0), 100);
}
/* -------------------------------------------------------------------------- */
/* Progress bar */
/* -------------------------------------------------------------------------- */
const progressVariants = cva("flex max-h-6 min-h-6 w-full", {
variants: {
layout: {
track: "items-center gap-1",
badge: "flex-col items-start justify-center",
},
},
defaultVariants: {
layout: "track",
},
});
const progressTrackVariants = cva(
"w-full overflow-hidden rounded-lg bg-bg-surface-1",
{
variants: {
thickness: {
thin: "h-1",
bold: "h-2",
},
},
defaultVariants: {
thickness: "thin",
},
},
);
const progressFillVariants = cva("h-full rounded-lg transition-all", {
variants: {
variant: {
accent: "bg-accent-default",
neutral: "bg-text-strong",
status: "bg-success-middle",
},
},
defaultVariants: {
variant: "accent",
},
});
const progressBadgeVariants = cva(
"inline-flex h-4 shrink-0 items-center justify-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] leading-[1.3] font-medium text-text-inverse",
{
variants: {
variant: {
accent: "bg-accent-default",
neutral: "bg-text-strong",
status: "bg-success-middle",
},
},
defaultVariants: {
variant: "accent",
},
},
);
export type ProgressProps = Omit<
React.ComponentProps<typeof ProgressPrimitive.Root>,
"children"
> &
VariantProps<typeof progressTrackVariants> & {
variant?: ProgressVariant;
/** Render the trailing percentage label. */
showValue?: boolean;
/** Text of the completion badge shown once the task reaches 100%. */
doneLabel?: string;
};
function Progress({
className,
value,
variant = "accent",
thickness = "thin",
showValue = true,
doneLabel = "Done",
...props
}: ProgressProps) {
const percent = clampPercent(value);
const done = percent >= 100;
return (
<ProgressPrimitive.Root
data-slot="progress"
data-variant={variant}
value={value}
className={cn(
progressVariants({ layout: done ? "badge" : "track" }),
className,
)}
{...props}
>
{done ? (
<span
data-slot="progress-badge"
className={cn(progressBadgeVariants({ variant }))}
>
<span
aria-hidden
className="flex size-[9px] shrink-0 items-center justify-center"
>
<span className="size-[5.625px] rounded-full bg-current" />
</span>
{doneLabel}
</span>
) : (
<>
<div className="flex min-w-px flex-1 flex-col items-start py-2">
<div className={cn(progressTrackVariants({ thickness }))}>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn(progressFillVariants({ variant }), "w-full")}
style={{ transform: `translateX(-${100 - percent}%)` }}
/>
</div>
</div>
{showValue && (
<span
data-slot="progress-value"
className="w-8 shrink-0 text-right text-sm leading-[1.4] font-normal text-text-strong"
>
{Math.round(percent)}%
</span>
)}
</>
)}
</ProgressPrimitive.Root>
);
}
/* -------------------------------------------------------------------------- */
/* Progress circle */
/* -------------------------------------------------------------------------- */
/** Ring box, stroke width, arc radius, filled-disc radius and check size, in px. */
const PROGRESS_CIRCLE_GEOMETRY = {
sm: { box: 20, stroke: 3.24, radius: 7.38, disc: 9, icon: 10 },
md: { box: 32, stroke: 3.92, radius: 12.04, disc: 14, icon: 16 },
lg: { box: 48, stroke: 4.4, radius: 19.8, disc: 22, icon: 24 },
} as const;
const progressCircleVariants = cva("relative shrink-0", {
variants: {
size: {
sm: "size-5",
md: "size-8",
lg: "size-12",
},
},
defaultVariants: {
size: "lg",
},
});
const progressCircleToneVariants = cva("size-full -rotate-90", {
variants: {
variant: {
accent: "text-accent-default",
neutral: "text-text-strong",
status: "text-success-middle",
},
},
defaultVariants: {
variant: "accent",
},
});
export type ProgressCircleProps = Omit<
React.ComponentProps<typeof ProgressPrimitive.Root>,
"children"
> &
VariantProps<typeof progressCircleVariants> & {
variant?: ProgressVariant;
/** Render the percentage inside the ring — the design only labels `lg`. */
showValue?: boolean;
};
function ProgressCircle({
className,
value,
variant = "accent",
size = "lg",
showValue = true,
...props
}: ProgressCircleProps) {
const percent = clampPercent(value);
const done = percent >= 100;
const { box, stroke, radius, disc, icon } =
PROGRESS_CIRCLE_GEOMETRY[size ?? "lg"];
const center = box / 2;
const circumference = 2 * Math.PI * radius;
return (
<ProgressPrimitive.Root
data-slot="progress-circle"
data-variant={variant}
value={value}
className={cn(progressCircleVariants({ size }), className)}
{...props}
>
<svg
aria-hidden
viewBox={`0 0 ${box} ${box}`}
className={cn(progressCircleToneVariants({ variant }))}
>
{done ? (
<circle cx={center} cy={center} r={disc} fill="currentColor" />
) : (
<>
<circle
cx={center}
cy={center}
r={radius}
fill="none"
strokeWidth={stroke}
className="stroke-bg-surface-1"
/>
<circle
data-slot="progress-circle-indicator"
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - percent / 100)}
className="transition-all"
/>
</>
)}
</svg>
{done ? (
<Check01
aria-hidden
strokeWidth={2}
style={{ width: icon, height: icon }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-icon-inverse"
/>
) : (
showValue &&
size === "lg" && (
<span
data-slot="progress-circle-value"
className="absolute inset-0 flex items-center justify-center text-xs leading-[1.3] font-normal text-text-strong"
>
{Math.round(percent)}%
</span>
)
)}
</ProgressPrimitive.Root>
);
}
export {
Progress,
ProgressCircle,
progressVariants,
progressTrackVariants,
progressFillVariants,
progressBadgeVariants,
progressCircleVariants,
progressCircleToneVariants,
};components/ui/progress.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cva, type VariantProps } from "class-variance-authority";
import { Check01 } from "@/components/icons/check-01";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Variant */
/* -------------------------------------------------------------------------- */
export type ProgressVariant = "accent" | "neutral" | "status";
function clampPercent(value: number | null | undefined) {
return Math.min(Math.max(value ?? 0, 0), 100);
}
/* -------------------------------------------------------------------------- */
/* Progress bar */
/* -------------------------------------------------------------------------- */
const progressVariants = cva("flex max-h-6 min-h-6 w-full", {
variants: {
layout: {
track: "items-center gap-1",
badge: "flex-col items-start justify-center",
},
},
defaultVariants: {
layout: "track",
},
});
const progressTrackVariants = cva(
"w-full overflow-hidden rounded-lg bg-bg-surface-1",
{
variants: {
thickness: {
thin: "h-1",
bold: "h-2",
},
},
defaultVariants: {
thickness: "thin",
},
},
);
const progressFillVariants = cva("h-full rounded-lg transition-all", {
variants: {
variant: {
accent: "bg-accent-default",
neutral: "bg-text-strong",
status: "bg-success-middle",
},
},
defaultVariants: {
variant: "accent",
},
});
const progressBadgeVariants = cva(
"inline-flex h-4 shrink-0 items-center justify-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] leading-[1.3] font-medium text-text-inverse",
{
variants: {
variant: {
accent: "bg-accent-default",
neutral: "bg-text-strong",
status: "bg-success-middle",
},
},
defaultVariants: {
variant: "accent",
},
},
);
export type ProgressProps = Omit<
ProgressPrimitive.Root.Props,
"children"
> &
VariantProps<typeof progressTrackVariants> & {
variant?: ProgressVariant;
/** Render the trailing percentage label. */
showValue?: boolean;
/** Text of the completion badge shown once the task reaches 100%. */
doneLabel?: string;
};
function Progress({
className,
value,
variant = "accent",
thickness = "thin",
showValue = true,
doneLabel = "Done",
...props
}: ProgressProps) {
const percent = clampPercent(value);
const done = percent >= 100;
return (
<ProgressPrimitive.Root
data-slot="progress"
data-variant={variant}
value={value}
className={cn(
progressVariants({ layout: done ? "badge" : "track" }),
className,
)}
{...props}
>
{done ? (
<span
data-slot="progress-badge"
className={cn(progressBadgeVariants({ variant }))}
>
<span
aria-hidden
className="flex size-[9px] shrink-0 items-center justify-center"
>
<span className="size-[5.625px] rounded-full bg-current" />
</span>
{doneLabel}
</span>
) : (
<>
<div className="flex min-w-px flex-1 flex-col items-start py-2">
{/* The indicator reads the value itself and sizes its width to the
* percentage inline — no translate trick needed. */}
<ProgressPrimitive.Track
className={cn(progressTrackVariants({ thickness }))}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn(progressFillVariants({ variant }))}
/>
</ProgressPrimitive.Track>
</div>
{showValue && (
<span
data-slot="progress-value"
className="w-8 shrink-0 text-right text-sm leading-[1.4] font-normal text-text-strong"
>
{Math.round(percent)}%
</span>
)}
</>
)}
</ProgressPrimitive.Root>
);
}
/* -------------------------------------------------------------------------- */
/* Progress circle */
/* -------------------------------------------------------------------------- */
/** Ring box, stroke width, arc radius, filled-disc radius and check size, in px. */
const PROGRESS_CIRCLE_GEOMETRY = {
sm: { box: 20, stroke: 3.24, radius: 7.38, disc: 9, icon: 10 },
md: { box: 32, stroke: 3.92, radius: 12.04, disc: 14, icon: 16 },
lg: { box: 48, stroke: 4.4, radius: 19.8, disc: 22, icon: 24 },
} as const;
const progressCircleVariants = cva("relative shrink-0", {
variants: {
size: {
sm: "size-5",
md: "size-8",
lg: "size-12",
},
},
defaultVariants: {
size: "lg",
},
});
const progressCircleToneVariants = cva("size-full -rotate-90", {
variants: {
variant: {
accent: "text-accent-default",
neutral: "text-text-strong",
status: "text-success-middle",
},
},
defaultVariants: {
variant: "accent",
},
});
export type ProgressCircleProps = Omit<
ProgressPrimitive.Root.Props,
"children"
> &
VariantProps<typeof progressCircleVariants> & {
variant?: ProgressVariant;
/** Render the percentage inside the ring — the design only labels `lg`. */
showValue?: boolean;
};
function ProgressCircle({
className,
value,
variant = "accent",
size = "lg",
showValue = true,
...props
}: ProgressCircleProps) {
const percent = clampPercent(value);
const done = percent >= 100;
const { box, stroke, radius, disc, icon } =
PROGRESS_CIRCLE_GEOMETRY[size ?? "lg"];
const center = box / 2;
const circumference = 2 * Math.PI * radius;
return (
<ProgressPrimitive.Root
data-slot="progress-circle"
data-variant={variant}
value={value}
className={cn(progressCircleVariants({ size }), className)}
{...props}
>
<svg
aria-hidden
viewBox={`0 0 ${box} ${box}`}
className={cn(progressCircleToneVariants({ variant }))}
>
{done ? (
<circle cx={center} cy={center} r={disc} fill="currentColor" />
) : (
<>
<circle
cx={center}
cy={center}
r={radius}
fill="none"
strokeWidth={stroke}
className="stroke-bg-surface-1"
/>
<circle
data-slot="progress-circle-indicator"
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - percent / 100)}
className="transition-all"
/>
</>
)}
</svg>
{done ? (
<Check01
aria-hidden
strokeWidth={2}
style={{ width: icon, height: icon }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-icon-inverse"
/>
) : (
showValue &&
size === "lg" && (
<span
data-slot="progress-circle-value"
className="absolute inset-0 flex items-center justify-center text-xs leading-[1.3] font-normal text-text-strong"
>
{Math.round(percent)}%
</span>
)
)}
</ProgressPrimitive.Root>
);
}
export {
Progress,
ProgressCircle,
progressVariants,
progressTrackVariants,
progressFillVariants,
progressBadgeVariants,
progressCircleVariants,
progressCircleToneVariants,
};