Stepped progress
Five stepped indicators: steps, dots, numbered, checks and pills. Each is sized and token-driven; the value maps to how many steps are complete.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/progress-steppedDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/progress-stepped.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
/* Stepped progress family: value 0-100 maps to roughly 5 discrete steps; the completed ones fill with a token. Colour comes ONLY from tokens. framer-motion settles the completed steps into place softly; they appear instantly under reduced-motion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const track: Record<StyledSize, string> = {
sm: "h-1",
md: "h-1.5",
lg: "h-2.5",
xl: "h-3.5",
}
const node: Record<StyledSize, string> = {
sm: "size-4 text-[9px]",
md: "size-5 text-[10px]",
lg: "size-6 text-xs",
xl: "size-7 text-sm",
}
const STEPS = 5
type Props = React.ComponentProps<"div"> & { size?: StyledSize; value?: number; label?: string }
function clamp(v: number) {
return Math.max(0, Math.min(100, v))
}
function stepsDone(v: number) {
return Math.round((clamp(v) / 100) * STEPS)
}
function useFill() {
const reduce = useReducedMotion()
return {
initial: reduce ? false : ({ scale: 0.5, opacity: 0 } as const),
transition: (i: number) => (reduce ? undefined : { duration: 0.35, ease: "easeOut" as const, delay: i * 0.06 }),
}
}
/* Steps: baglayicilarla ayrilmis 5 bar segmenti; tamamlananlar token dolu. */
export function StepsProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const v = clamp(value)
const filled = stepsDone(v)
const { initial, transition } = useFill()
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("flex w-56 max-w-full items-center gap-1.5", track[size], className)}
{...props}
>
{Array.from({ length: STEPS }).map((_, i) => (
<span key={i} className="relative h-full flex-1 overflow-hidden rounded-full bg-secondary">
{i < filled && (
<motion.span
className="absolute inset-0 rounded-full bg-primary"
initial={initial}
animate={{ scale: 1, opacity: 1 }}
transition={transition(i)}
/>
)}
</span>
))}
</div>
)
}
/* DotsStep: cizgilerle birlesmis noktalar; tamamlanan nokta ve baglantilar dolu. */
export function DotsStepProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const v = clamp(value)
const filled = stepsDone(v)
const { initial, transition } = useFill()
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("flex w-56 max-w-full items-center", className)}
{...props}
>
{Array.from({ length: STEPS }).map((_, i) => (
<React.Fragment key={i}>
<motion.span
className={cn("size-2.5 shrink-0 rounded-full", i < filled ? "bg-primary" : "bg-secondary")}
initial={i < filled ? initial : false}
animate={{ scale: 1, opacity: 1 }}
transition={i < filled ? transition(i) : undefined}
/>
{i < STEPS - 1 && (
<span
className={cn("h-0.5 flex-1 rounded-full", i < filled - 1 ? "bg-primary" : "bg-secondary")}
/>
)}
</React.Fragment>
))}
</div>
)
}
/* Numbered: 1..5 numarali adim daireleri; tamamlananlar token dolu. */
export function NumberedProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const v = clamp(value)
const filled = stepsDone(v)
const { initial, transition } = useFill()
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("flex w-56 max-w-full items-center", className)}
{...props}
>
{Array.from({ length: STEPS }).map((_, i) => {
const done = i < filled
return (
<React.Fragment key={i}>
<motion.span
className={cn(
"flex shrink-0 items-center justify-center rounded-full font-medium tabular-nums",
node[size],
done ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground"
)}
initial={done ? initial : false}
animate={{ scale: 1, opacity: 1 }}
transition={done ? transition(i) : undefined}
>
{i + 1}
</motion.span>
{i < STEPS - 1 && (
<span className={cn("h-0.5 flex-1 rounded-full", i < filled - 1 ? "bg-primary" : "bg-secondary")} />
)}
</React.Fragment>
)
})}
</div>
)
}
/* CheckStep: completed steps show a check. */
export function CheckStepProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const v = clamp(value)
const filled = stepsDone(v)
const { initial, transition } = useFill()
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("flex w-56 max-w-full items-center", className)}
{...props}
>
{Array.from({ length: STEPS }).map((_, i) => {
const done = i < filled
return (
<React.Fragment key={i}>
<motion.span
className={cn(
"flex shrink-0 items-center justify-center rounded-full [&>svg]:size-3",
node[size],
done ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground"
)}
initial={done ? initial : false}
animate={{ scale: 1, opacity: 1 }}
transition={done ? transition(i) : undefined}
>
{done ? <Check aria-hidden="true" /> : <span className="size-1.5 rounded-full bg-current opacity-60" />}
</motion.span>
{i < STEPS - 1 && (
<span className={cn("h-0.5 flex-1 rounded-full", i < filled - 1 ? "bg-primary" : "bg-secondary")} />
)}
</React.Fragment>
)
})}
</div>
)
}
/* PillStep: hap (pill) segmentler; tamamlananlar token dolu. */
export function PillStepProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const v = clamp(value)
const filled = stepsDone(v)
const { initial, transition } = useFill()
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("flex w-56 max-w-full items-center gap-1", track[size], className)}
{...props}
>
{Array.from({ length: STEPS }).map((_, i) => (
<span
key={i}
className="relative h-full flex-1 overflow-hidden rounded-full bg-secondary ring-1 ring-inset ring-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]"
>
{i < filled && (
<motion.span
className="absolute inset-0 rounded-full bg-primary"
initial={initial}
animate={{ scale: 1, opacity: 1 }}
transition={transition(i)}
/>
)}
</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.
Steps
Discrete steps with connectors.
import { StepsProgress } from "@/components/ui/progress-stepped"
<StepsProgress value={60} />Dots
Dots joined by lines.
import { DotsStepProgress } from "@/components/ui/progress-stepped"
<DotsStepProgress value={60} />Numbered
Numbered steps.
import { NumberedProgress } from "@/components/ui/progress-stepped"
<NumberedProgress value={60} />Check
Completed steps show a check.
import { CheckStepProgress } from "@/components/ui/progress-stepped"
<CheckStepProgress value={60} />Pill
Pill-shaped segments.
import { PillStepProgress } from "@/components/ui/progress-stepped"
<PillStepProgress value={60} />ai2 Stepped progress: 5 styled variations on the token system
The ai2 Stepped progress are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around step-based progress. 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: these update on token transitions as steps complete. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transitions are disabled and steps switch instantly.
What is in the ai2 Stepped progress?
5 exports in one file: Steps, Dots, Numbered, Check and Pill. 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: these update on token transitions as steps complete.
- Reduced-motion aware: Under prefers-reduced-motion, the transitions are disabled and steps switch instantly.
- 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 Stepped 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.