/Click feedback
Click feedback • React
Progress Ring Check
A task status icon whose dashed ring closes into a spinning arc, fills to its progress, then draws a check or a cross and strikes the label through.
Installation
Requires motion for its icons. The shadcn CLI installs it for you; copying the code by hand means installing it yourself.
Terminal
npx shadcn@latest add @microkit/progress-ring-checkCode
1"use client";23import { useEffect, useLayoutEffect, useRef, useState } from "react";4import type { CSSProperties, ReactNode } from "react";5import { animate, useMotionValue, useReducedMotion } from "motion/react";67export type ProgressRingCheckStatus = "pending" | "running" | "done" | "failed" | "cancelled";89export type ProgressRingCheckProps = {10status?: ProgressRingCheckStatus;11progress?: number;12label?: ReactNode;13color?: string;14doneColor?: string;15errorColor?: string;16size?: number;17strokeWidth?: number;18dashes?: number;19fontSize?: number;20spinDuration?: number;21arcLength?: number;22drawDuration?: number;23fillOpacity?: number;24strike?: boolean;25strikeDelay?: number;26className?: string;27style?: CSSProperties;28};2930const UI = { type: "spring" as const, duration: 0.3, bounce: 0 };31const MORPH = { duration: 0.3, ease: [0.77, 0, 0.175, 1] as [number, number, number, number] };32const CHECK = "M7.5 12.25 10.5 15.25 16.75 8.75";33const CROSS = "M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5";34const TEXT: Record<ProgressRingCheckStatus, string> = {35pending: "Pending",36running: "In progress",37done: "Completed",38failed: "Failed",39cancelled: "Cancelled",40};41const IDLE_DASH = 0.3;42const STYLE = "@keyframes prc-breathe{50%{opacity:.45}}";4344const clamp01 = (value: number) => Math.min(1, Math.max(0, value));4546export function ProgressRingCheck({47status = "pending",48progress,49label,50color = "currentColor",51doneColor = "#22c55e",52errorColor = "#ef4444",53size = 20,54strokeWidth = 2,55dashes = 8,56fontSize = 14,57spinDuration = 1100,58arcLength = 0.68,59drawDuration = 240,60fillOpacity = 0.06,61strike = true,62strikeDelay = 60,63className = "",64style,65}: ProgressRingCheckProps) {66const reduce = useReducedMotion();67const r = 10 - strokeWidth / 2;68const C = 2 * Math.PI * r;69const P = C / Math.max(1, dashes);70const determinate = status === "running" && typeof progress === "number" && Number.isFinite(progress);71const indeterminate = status === "running" && !determinate;72const solid = status === "running" || status === "done" || status === "failed";73const targetArc = indeterminate ? arcLength : determinate ? clamp01(progress as number) : 1;7475const mode = useMotionValue(solid ? 1 : 0);76const arc = useMotionValue(targetArc);77const travel = useMotionValue(0);78const ringRef = useRef<SVGCircleElement>(null);79const geo = useRef({ C, P });80const gen = useRef(0);8182const writeDash = () => {83const g = geo.current;84const m = mode.get();85const a = arc.get();86const dash = IDLE_DASH * g.P + (a * g.C - IDLE_DASH * g.P) * m;87const gap = (1 - IDLE_DASH) * g.P + ((1 - a) * g.C - (1 - IDLE_DASH) * g.P) * m;88ringRef.current?.setAttribute("stroke-dasharray", `${Math.max(0, dash)} ${Math.max(0, gap)}`);89};9091useLayoutEffect(() => {92geo.current = { C, P };93writeDash();94ringRef.current?.setAttribute("stroke-dashoffset", String(travel.get()));95// eslint-disable-next-line react-hooks/exhaustive-deps96}, [C, P]);9798useEffect(() => {99const offs = [100mode.on("change", writeDash),101arc.on("change", writeDash),102travel.on("change", (value: number) => ringRef.current?.setAttribute("stroke-dashoffset", String(value))),103];104return () => {105offs.forEach((off) => off());106mode.stop();107arc.stop();108travel.stop();109};110// eslint-disable-next-line react-hooks/exhaustive-deps111}, []);112113useEffect(() => {114const g = ++gen.current;115if (reduce) {116mode.jump(solid ? 1 : 0);117arc.jump(targetArc);118travel.jump(0);119return;120}121if (mode.get() === 0) arc.jump(targetArc);122animate(mode, solid ? 1 : 0, MORPH);123animate(arc, targetArc, UI);124if (indeterminate) {125const t0 = travel.get();126animate(travel, [t0, t0 - C], { duration: spinDuration / 1000, ease: "linear", repeat: Infinity });127return;128}129const unit = determinate ? C : P;130const to = Math.floor(travel.get() / unit) * unit;131animate(travel, to, UI).then(() => {132if (gen.current === g) travel.jump(0);133});134// eslint-disable-next-line react-hooks/exhaustive-deps135}, [status, determinate, targetArc, reduce, C, P, spinDuration]);136137const spoken = TEXT[status] + (determinate ? `, ${Math.round(clamp01(progress as number) * 100)}%` : "");138const hasLabel = label !== undefined && label !== null;139140return (141<span142className={`group relative inline-flex items-center align-middle leading-none [gap:calc(var(--prc-size)*0.5)]${className ? ` ${className}` : ""}`}143data-status={status}144data-indeterminate={indeterminate ? "" : undefined}145data-strike={strike ? "" : undefined}146style={147{148"--prc-size": `${size}px`,149"--prc-stroke": strokeWidth,150"--prc-color": color,151"--prc-done": doneColor,152"--prc-error": errorColor,153"--prc-fill": fillOpacity,154"--prc-font": `${fontSize}px`,155"--prc-draw": `${drawDuration}ms`,156"--prc-strike-delay": `${120 + strikeDelay}ms`,157"--prc-check-delay": "120ms",158...style,159} as CSSProperties160}161>162<style>{STYLE}</style>163<svg164className="shrink-0 overflow-visible [color:var(--prc-color)] [transition:color_200ms_ease] group-data-[status=done]:[color:var(--prc-done)] group-data-[status=failed]:[color:var(--prc-error)]"165viewBox="0 0 24 24"166width={size}167height={size}168role={hasLabel ? undefined : "img"}169aria-label={hasLabel ? undefined : spoken}170aria-hidden={hasLabel || undefined}171>172<circle className="[fill:currentColor] [stroke:currentColor] [stroke-width:var(--prc-stroke)] [fill-opacity:0] [stroke-opacity:0] [transition:fill-opacity_180ms_ease,stroke-opacity_200ms_ease] group-data-[status=running]:[stroke-opacity:0.2] group-data-[status=done]:[fill-opacity:var(--prc-fill)] group-data-[status=failed]:[fill-opacity:var(--prc-fill)]" cx="12" cy="12" r={r} transform="rotate(-90 12 12)" />173<circle ref={ringRef} className="fill-none [stroke:currentColor] [stroke-width:var(--prc-stroke)] [stroke-linecap:round] opacity-[0.55] [transition:opacity_200ms_ease] group-data-[status=running]:opacity-100 group-data-[status=done]:opacity-100 group-data-[status=failed]:opacity-100 motion-reduce:group-data-[indeterminate]:animate-[prc-breathe_1400ms_cubic-bezier(0.77,0,0.175,1)_infinite]" cx="12" cy="12" r={r} transform="rotate(-90 12 12)" />174<path className="fill-none [stroke:currentColor] [stroke-width:var(--prc-stroke)] [stroke-linecap:round] [stroke-linejoin:round] [stroke-dasharray:1_2] [stroke-dashoffset:1.05] opacity-0 [transition:stroke-dashoffset_160ms_cubic-bezier(0.23,1,0.32,1),opacity_0ms_linear_160ms] motion-reduce:[stroke-dashoffset:0] motion-reduce:[transition:opacity_200ms_ease] group-data-[status=done]:[stroke-dashoffset:0] group-data-[status=done]:opacity-100 group-data-[status=done]:[transition:stroke-dashoffset_var(--prc-draw)_cubic-bezier(0.23,1,0.32,1)_var(--prc-check-delay),opacity_0ms_linear_var(--prc-check-delay)] motion-reduce:group-data-[status=done]:[transition:opacity_200ms_ease]" d={CHECK} pathLength="1" />175<path className="fill-none [stroke:currentColor] [stroke-width:var(--prc-stroke)] [stroke-linecap:round] [stroke-linejoin:round] [stroke-dasharray:1_2] [stroke-dashoffset:1.05] opacity-0 [transition:stroke-dashoffset_160ms_cubic-bezier(0.23,1,0.32,1),opacity_0ms_linear_160ms] motion-reduce:[stroke-dashoffset:0] motion-reduce:[transition:opacity_200ms_ease] group-data-[status=failed]:[stroke-dashoffset:0] group-data-[status=failed]:opacity-100 group-data-[status=failed]:[transition:stroke-dashoffset_var(--prc-draw)_cubic-bezier(0.23,1,0.32,1)_var(--prc-check-delay),opacity_0ms_linear_var(--prc-check-delay)] motion-reduce:group-data-[status=failed]:[transition:opacity_200ms_ease] group-data-[status=cancelled]:[stroke-dashoffset:0] group-data-[status=cancelled]:opacity-100 group-data-[status=cancelled]:[transition:stroke-dashoffset_var(--prc-draw)_cubic-bezier(0.23,1,0.32,1)_var(--prc-check-delay),opacity_0ms_linear_var(--prc-check-delay)] motion-reduce:group-data-[status=cancelled]:[transition:opacity_200ms_ease]" d={CROSS} pathLength="1" />176</svg>177{hasLabel ? <span className="sr-only">{spoken}: </span> : null}178{hasLabel ? (179<span className="relative leading-[1.25] opacity-[0.65] [color:var(--prc-color)] [font-size:var(--prc-font)] [transition:opacity_200ms_ease] group-data-[status=running]:opacity-100 group-data-[status=done]:opacity-60 group-data-[status=failed]:opacity-100 group-data-[status=cancelled]:opacity-[0.55]">180{label}181<span className="pointer-events-none absolute inset-x-0 top-1/2 origin-left bg-current [transform:scaleX(0)] [translate:0_-50%] [height:max(1px,calc(var(--prc-font)/14))] [transition:transform_160ms_cubic-bezier(0.23,1,0.32,1)] group-data-[status=done]:group-data-[strike]:[transform:scaleX(1)] group-data-[status=done]:group-data-[strike]:[transition:transform_280ms_cubic-bezier(0.23,1,0.32,1)_var(--prc-strike-delay)] motion-reduce:[transform:scaleX(1)]! motion-reduce:opacity-0 motion-reduce:[transition:opacity_200ms_ease] motion-reduce:group-data-[status=done]:group-data-[strike]:opacity-100 motion-reduce:group-data-[status=done]:group-data-[strike]:[transition:opacity_200ms_ease]" aria-hidden="true" />182</span>183) : null}184</span>185);186}187188const tasks: { label: string; status: ProgressRingCheckStatus }[] = [189{ label: "Sync calendar events", status: "done" },190{ label: "Generate weekly report", status: "running" },191{ label: "Notify the team", status: "pending" },192];193194const SPIN_MS = 1600;195196const nextStatus: Record<Exclude<ProgressRingCheckStatus, "pending" | "running">, ProgressRingCheckStatus> = {197done: "failed",198failed: "cancelled",199cancelled: "pending",200};201202export function ProgressRingCheckList() {203const [statuses, setStatuses] = useState(() => tasks.map((task) => task.status));204const timers = useRef<number[]>([]);205206const update = (index: number, status: ProgressRingCheckStatus) =>207setStatuses((current) => current.map((value, i) => (i === index ? status : value)));208209const finishLater = (index: number) => {210timers.current.push(window.setTimeout(() => update(index, "done"), SPIN_MS));211};212213useEffect(() => {214const pending = timers.current;215tasks.forEach((task, index) => {216if (task.status !== "running") return;217const finish = () => setStatuses((current) => current.map((value, i) => (i === index ? "done" : value)));218pending.push(window.setTimeout(finish, SPIN_MS));219});220return () => pending.forEach((timer) => window.clearTimeout(timer));221}, []);222223const advance = (index: number) => {224const status = statuses[index];225if (status === "pending") {226update(index, "running");227finishLater(index);228} else if (status !== "running") {229update(index, nextStatus[status]);230}231};232233return (234<div className="flex flex-col gap-[2px]">235{tasks.map((task, index) => (236<button key={task.label} type="button" className="flex min-h-[44px] cursor-pointer appearance-none items-center rounded-[12px] border-0 bg-transparent px-[14px] text-left [font-family:inherit] text-[light-dark(#14181e,#f2f4f6)] [transition:background-color_.2s_ease] hover:bg-[light-dark(#f0f2f5,#1b1e23)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f97316] motion-reduce:transition-none" onClick={() => advance(index)}>237<ProgressRingCheck238status={statuses[index]}239label={task.label}240size={22}241fontSize={16}242/>243</button>244))}245</div>246);247}