Circular progress
Five circular rings: an SVG ring with a label, a conic ring, dual arcs, a dashed ring and a glowing ring. Each is sized and token-driven, with a value prop.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/progress-circularDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/progress-circular.tsx"use client"
import type * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Circular progress family: SVG rings inside a square box. The arc = value. Colour comes ONLY from tokens (stroke=var(--color-info) and so on). framer-motion drives strokeDashoffset and dasharray; under reduced-motion the arc snaps straight to its filled position. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const box: Record<StyledSize, string> = {
sm: "size-10",
md: "size-12",
lg: "size-14",
xl: "size-16",
}
const R = 42
const C = 2 * Math.PI * R
type Props = React.ComponentProps<"div"> & { size?: StyledSize; value?: number; label?: string }
function clamp(v: number) {
return Math.max(0, Math.min(100, v))
}
/* Ring: tek SVG yay (stroke-dasharray), ortada % metni. */
export function RingProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
const offset = C * (1 - v / 100)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("relative grid place-items-center", box[size], className)}
{...props}
>
<svg viewBox="0 0 100 100" className="size-full -rotate-90">
<circle cx="50" cy="50" r={R} fill="none" strokeWidth="9" className="stroke-secondary" />
<motion.circle
cx="50"
cy="50"
r={R}
fill="none"
strokeWidth="9"
strokeLinecap="round"
stroke="var(--color-info)"
strokeDasharray={C}
initial={reduce ? false : { strokeDashoffset: C }}
animate={{ strokeDashoffset: offset }}
transition={reduce ? undefined : { duration: 0.9, ease: "easeOut" }}
/>
</svg>
<span className="absolute text-xs font-medium tabular-nums text-foreground">{Math.round(v)}%</span>
</div>
)
}
/* Conic: a token conic-gradient ring; the arc matches the value. It is carved into
the ring with a mask. */
export function ConicProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("relative grid place-items-center", box[size], className)}
{...props}
>
<motion.div
aria-hidden="true"
className="size-full rounded-full [-webkit-mask:radial-gradient(farthest-side,transparent_calc(100%-6px),var(--color-foreground)_0)] [background:conic-gradient(var(--color-info)_calc(var(--p)*3.6deg),var(--color-secondary)_0)] [mask:radial-gradient(farthest-side,transparent_calc(100%-6px),var(--color-foreground)_0)]"
initial={reduce ? false : ({ "--p": 0 } as Record<string, number>)}
animate={{ "--p": v } as Record<string, number>}
transition={reduce ? undefined : { duration: 0.9, ease: "easeOut" }}
/>
<span className="absolute text-xs font-medium tabular-nums text-foreground">{Math.round(v)}%</span>
</div>
)
}
/* DualRing: ic ice iki yay (dis info, ic success); ikisi de value kadar dolar. */
export function DualRingProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
const rOuter = 42
const rInner = 30
const cOuter = 2 * Math.PI * rOuter
const cInner = 2 * Math.PI * rInner
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("relative grid place-items-center", box[size], className)}
{...props}
>
<svg viewBox="0 0 100 100" className="size-full -rotate-90">
<circle cx="50" cy="50" r={rOuter} fill="none" strokeWidth="7" className="stroke-secondary" />
<circle cx="50" cy="50" r={rInner} fill="none" strokeWidth="7" className="stroke-secondary" />
<motion.circle
cx="50"
cy="50"
r={rOuter}
fill="none"
strokeWidth="7"
strokeLinecap="round"
stroke="var(--color-info)"
strokeDasharray={cOuter}
initial={reduce ? false : { strokeDashoffset: cOuter }}
animate={{ strokeDashoffset: cOuter * (1 - v / 100) }}
transition={reduce ? undefined : { duration: 0.9, ease: "easeOut" }}
/>
<motion.circle
cx="50"
cy="50"
r={rInner}
fill="none"
strokeWidth="7"
strokeLinecap="round"
stroke="var(--color-success)"
strokeDasharray={cInner}
initial={reduce ? false : { strokeDashoffset: cInner }}
animate={{ strokeDashoffset: cInner * (1 - v / 100) }}
transition={reduce ? undefined : { duration: 0.9, ease: "easeOut", delay: 0.1 }}
/>
</svg>
</div>
)
}
/* DashRing: cizgili (dashed) halka; info yay dolarken ustteki token dash gaplari oyar. */
export function DashRingProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("relative grid place-items-center", box[size], className)}
{...props}
>
<svg viewBox="0 0 100 100" className="size-full -rotate-90">
<motion.circle
cx="50"
cy="50"
r={R}
fill="none"
strokeWidth="8"
strokeLinecap="butt"
stroke="var(--color-info)"
pathLength={100}
initial={reduce ? false : { strokeDasharray: "0 100" }}
animate={{ strokeDasharray: `${v} 100` }}
transition={reduce ? undefined : { duration: 0.9, ease: "easeOut" }}
/>
<circle
cx="50"
cy="50"
r={R}
fill="none"
strokeWidth="9"
strokeDasharray="3 4"
className="stroke-secondary"
/>
</svg>
</div>
)
}
/* GlowRing: info yay + token drop-shadow parlama; ortada % metni. */
export function GlowRingProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
const offset = C * (1 - v / 100)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("relative grid place-items-center", box[size], className)}
{...props}
>
<svg viewBox="0 0 100 100" className="size-full -rotate-90">
<circle cx="50" cy="50" r={R} fill="none" strokeWidth="8" className="stroke-secondary" />
<motion.circle
cx="50"
cy="50"
r={R}
fill="none"
strokeWidth="8"
strokeLinecap="round"
stroke="var(--color-info)"
className="[filter:drop-shadow(0_0_4px_color-mix(in_oklab,var(--color-info)_70%,transparent))]"
strokeDasharray={C}
initial={reduce ? false : { strokeDashoffset: C }}
animate={{ strokeDashoffset: offset }}
transition={reduce ? undefined : { duration: 0.9, ease: "easeOut" }}
/>
</svg>
<span className="absolute text-xs font-medium tabular-nums text-foreground">{Math.round(v)}%</span>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Ring
An SVG arc with the percentage centered.
import { RingProgress } from "@/components/ui/progress-circular"
<RingProgress value={60} />Conic
A conic-gradient token ring.
import { ConicProgress } from "@/components/ui/progress-circular"
<ConicProgress value={60} />Dual ring
Two concentric arcs.
import { DualRingProgress } from "@/components/ui/progress-circular"
<DualRingProgress value={60} />Dashed
A dashed ring filling to the value.
import { DashRingProgress } from "@/components/ui/progress-circular"
<DashRingProgress value={60} />Glow
A ring with a token glow.
import { GlowRingProgress } from "@/components/ui/progress-circular"
<GlowRingProgress value={60} />ai2 Circular progress: 5 styled variations on the token system
The ai2 Circular progress are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around circular progress rings. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion animates the stroke offset and conic arc to the value. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the arc lands instantly with no animation.
What is in the ai2 Circular progress?
5 exports in one file: Ring, Conic, Dual ring, Dashed and Glow. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion animates the stroke offset and conic arc to the value.
- Reduced-motion aware: Under prefers-reduced-motion, the arc lands instantly with no animation.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Circular progress sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.