Progress Circular
A shadcn-style progress circular component built with Ark UI primitives.
50%
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularDefault = () => (
<ProgressCircular defaultValue={50} showCenterValue size={112} />
);
export default ProgressCircularDefault;
Installation
npx shadcn@latest add @ark-cn/progress-circularInstall the dependency required by this primitive:
npm install @ark-ui/react class-variance-authorityCopy the component source into your app:
TSXcomponents/ui/progress-circular.tsx
"use client";
import {
Progress as ProgressPrimitive,
useProgress,
useProgressContext,
} from "@ark-ui/react/progress";
import { cva, type VariantProps } from "class-variance-authority";
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
export { useProgress, useProgressContext };
export type ProgressCircularRootProps = ProgressPrimitive.RootProps;
export const ProgressCircularRoot = ({
className,
...props
}: ProgressCircularRootProps) => (
<ProgressPrimitive.Root
className={cn(
"group/progress-circular flex flex-col items-center gap-2",
className,
)}
data-slot="progress-circular"
{...props}
/>
);
export type ProgressCircularLabelProps = ProgressPrimitive.LabelProps;
export const ProgressCircularLabel = ({
className,
...props
}: ProgressCircularLabelProps) => (
<ProgressPrimitive.Label
className={cn(
"w-full text-start font-medium text-foreground text-sm leading-none",
className,
)}
data-slot="progress-circular-label"
{...props}
/>
);
export type ProgressCircularValueTextProps = ProgressPrimitive.ValueTextProps;
export const ProgressCircularValueText = ({
className,
...props
}: ProgressCircularValueTextProps) => (
<ProgressPrimitive.ValueText
className={cn("tabular-nums text-foreground text-sm", className)}
data-slot="progress-circular-value-text"
{...props}
/>
);
export type ProgressCircularCircleProps = ProgressPrimitive.CircleProps;
export const ProgressCircularCircle = ({
className,
style,
...props
}: ProgressCircularCircleProps) => (
<ProgressPrimitive.Circle
className={cn(
"text-primary",
"group-data-[state=indeterminate]/progress-circular:animate-spin",
className,
)}
data-slot="progress-circular-circle"
style={style}
{...props}
/>
);
export type ProgressCircularTrackProps = ProgressPrimitive.CircleTrackProps;
export const ProgressCircularTrack = ({
className,
...props
}: ProgressCircularTrackProps) => (
<ProgressPrimitive.CircleTrack
className={cn("stroke-current text-muted", className)}
data-slot="progress-circular-track"
{...props}
/>
);
export type ProgressCircularRangeProps = ProgressPrimitive.CircleRangeProps;
export const ProgressCircularRange = ({
className,
...props
}: ProgressCircularRangeProps) => (
<ProgressPrimitive.CircleRange
className={cn(
"stroke-current text-primary [stroke-linecap:round]",
"transition-[stroke-dashoffset] duration-300 ease-out",
className,
)}
data-slot="progress-circular-range"
{...props}
/>
);
export type ProgressCircularRootProviderProps =
ProgressPrimitive.RootProviderProps;
export const ProgressCircularRootProvider = ({
className,
...props
}: ProgressCircularRootProviderProps) => (
<ProgressPrimitive.RootProvider
className={cn(
"group/progress-circular flex flex-col items-center gap-2",
className,
)}
data-slot="progress-circular-root-provider"
{...props}
/>
);
const progressCircularVariants = cva("flex items-center gap-2", {
defaultVariants: {
variant: "ring",
},
variants: {
variant: {
labeled: "flex-col",
ring: "flex-col",
row: "flex-row",
},
},
});
export type ProgressCircularProps = Omit<
ProgressCircularRootProps,
"children" | "className" | "style"
> &
VariantProps<typeof progressCircularVariants> & {
children?: ReactNode;
className?: string;
footer?: ReactNode;
label?: ReactNode;
showCenterValue?: boolean;
showThumb?: boolean;
size?: number;
style?: ProgressCircularRootProps["style"];
thickness?: number;
};
export const ProgressCircularThumb = ({
className,
}: {
className?: string;
}) => {
const api = useProgressContext();
if (api.indeterminate) {
return null;
}
const deg = api.percent * 3.6;
return (
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div
className="origin-center"
style={{ transform: `rotate(${deg}deg)` }}
>
<div
className={cn(
"absolute left-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-sm ring-2 ring-primary/35",
className,
)}
style={{
top: "calc(var(--thickness) / 2 - var(--size) / 2)",
}}
/>
</div>
</div>
</div>
);
};
export const ProgressCircular = ({
children,
className,
footer,
label,
showCenterValue = false,
showThumb = false,
size = 120,
style,
thickness = 8,
variant = "ring",
...rootProps
}: ProgressCircularProps) => {
const v = variant ?? "ring";
const cssVars = {
"--size": `${size}px`,
"--thickness": `${thickness}px`,
} as CSSProperties;
return (
<ProgressCircularRoot
className={cn(progressCircularVariants({ variant: v }), className)}
style={{ ...cssVars, ...style }}
{...rootProps}
>
{label != null ? (
<ProgressCircularLabel className={cn(v === "row" && "w-auto shrink-0")}>
{label}
</ProgressCircularLabel>
) : null}
<div className="relative inline-flex shrink-0" style={{ ...cssVars }}>
<ProgressCircularCircle>
<ProgressCircularTrack />
<ProgressCircularRange />
</ProgressCircularCircle>
{showCenterValue ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<ProgressCircularValueText className="font-semibold text-[0.95rem]" />
</div>
) : null}
{showThumb ? <ProgressCircularThumb /> : null}
</div>
{footer}
{children}
</ProgressCircularRoot>
);
};
export { progressCircularVariants };
Update import aliases to match your project setup.
Usage
import * as ProgressCircular from "@/components/ui/progress-circular"Read exported parts in src/components/ui/progress-circular.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Default
50%
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularDefault = () => (
<ProgressCircular defaultValue={50} showCenterValue size={112} />
);
export default ProgressCircularDefault;
Min and max
1
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularMinMax = () => (
<ProgressCircular
defaultValue={20}
formatOptions={{ maximumFractionDigits: 0, style: "decimal" }}
max={30}
min={10}
showCenterValue
size={112}
/>
);
export default ProgressCircularMinMax;
Indeterminate
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularIndeterminate = () => (
<ProgressCircular showThumb value={null} size={112} />
);
export default ProgressCircularIndeterminate;
Label
Uploading files
72%
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularLabel = () => (
<ProgressCircular
label="Uploading files"
showCenterValue
size={112}
value={72}
variant="labeled"
/>
);
export default ProgressCircularLabel;
Root provider
useProgress + RootProvider value 36
External control36%
import type { CSSProperties } from "react";
import { Button } from "@/components/ui/button";
import {
ProgressCircularCircle,
ProgressCircularLabel,
ProgressCircularRootProvider as ProgressCircularProvider,
ProgressCircularRange,
ProgressCircularTrack,
ProgressCircularValueText,
useProgress,
} from "@/components/ui/progress-circular";
const ProgressCircularRootProviderDemo = () => {
const store = useProgress({ defaultValue: 36 });
return (
<ProgressCircularProvider
className="max-w-xs"
style={
{
"--size": "112px",
"--thickness": "8px",
} as CSSProperties
}
value={store}
>
<p className="text-muted-foreground text-xs">
useProgress + RootProvider value{" "}
<span className="font-medium text-foreground tabular-nums">
{store.value ?? "-"}
</span>
</p>
<ProgressCircularLabel>External control</ProgressCircularLabel>
<div className="relative inline-flex">
<ProgressCircularCircle className="text-primary [--thickness:8px] [--size:112px]">
<ProgressCircularTrack />
<ProgressCircularRange />
</ProgressCircularCircle>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<ProgressCircularValueText className="font-semibold text-[0.9rem] tabular-nums" />
</div>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button
onClick={() => store.setValue(Math.max(0, (store.value ?? 0) - 15))}
size="sm"
type="button"
variant="outline"
>
-15
</Button>
<Button
onClick={() => store.setValue(Math.min(100, (store.value ?? 0) + 15))}
size="sm"
type="button"
variant="outline"
>
+15
</Button>
<Button
onClick={() => store.setToMin()}
size="sm"
type="button"
variant="secondary"
>
Min
</Button>
<Button
onClick={() => store.setToMax()}
size="sm"
type="button"
variant="secondary"
>
Max
</Button>
</div>
</ProgressCircularProvider>
);
};
export default ProgressCircularRootProviderDemo;
Ring variant
45%
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularRing = () => (
<ProgressCircular
defaultValue={45}
showCenterValue
size={160}
variant="ring"
/>
);
export default ProgressCircularRing;
Thumb
64%
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularThumb = () => (
<ProgressCircular
defaultValue={64}
showCenterValue
showThumb
size={128}
thickness={10}
/>
);
export default ProgressCircularThumb;
Controlled
42%
Controlled 42
import { useState } from "react";
import { ProgressCircular } from "@/components/ui/progress-circular";
const ProgressCircularControlled = () => {
const [value, setValue] = useState(42);
return (
<div className="flex w-full max-w-xs flex-col items-center gap-3">
<ProgressCircular
onValueChange={(details) => setValue(details.value ?? 0)}
showCenterValue
size={120}
value={value}
/>
<input
aria-label="Progress value"
className="w-full accent-primary"
max={100}
min={0}
onChange={(event) => setValue(Number(event.target.value))}
type="range"
value={value}
/>
<p className="text-muted-foreground text-xs">
Controlled <span className="font-mono text-foreground">{value}</span>
</p>
</div>
);
};
export default ProgressCircularControlled;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
ProgressCircular
| Prop | Type | Description |
|---|---|---|
| variant? | "labeled" | "ring" | "row" | Layout variant. |
| footer? | ReactNode | Renders below the ring. |
| label? | ReactNode | Optional title above the ring. |
| showCenterValue? | boolean | Overlays value text in the center. |
| showThumb? | boolean | Renders a thumb on the ring. |
| size? | number | Outer diameter in px (default 120). |
| thickness? | number | Stroke thickness (default 8). |
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.