Components
Status icon
The small status glyph beside a task: a dashed ring for backlog, an open ring for todo, a ring filling like a pie while in progress and in review, a filled disc with a tick when done and a crossed disc when cancelled. Every state is built from the same four shapes, so a change animates from one to the next.
pnpm dlx shadcn@latest add @caliper/status-iconWhen to use
Use it for
- Issue trackers, task lists, kanban cards and release checklists.
- Anywhere status sits beside a title and must read at 16px.
Skip it when
- A status that needs words to be understood the first time; pair it with a label or a tooltip.
- More than these six states; it is a shape language, not a palette.
Decisions
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- One set of shapes
- Ring, pie, disc and mark are always there; a status sets their dashes, fill and scale, so a change is a transition instead of a swap.
- Named
- It is an image named by its status ("In progress"); pass
label=""when the row already says it.
Styling
- Shape first, then colour
- States differ by shape and fill, so they read for people who cannot tell the colours apart; the hue (amber working, blue in review, green done, grey waiting) only confirms it and eases across with the shape.
Installation
pnpm dlx shadcn@latest add @caliper/status-icon
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage
This is the code behind the preview above.
import { useState, type ReactNode } from "react" import { StatusIcon, type IssueStatus } from "@/components/ui/status-icon" function Panel({ title, context, className = "max-w-md", children }: { title: string; context: string; className?: string; children: ReactNode }) { return ( <section className={`grid w-full gap-5 rounded-overlay border border-border bg-background p-6 shadow-resting max-sm:p-4 ${className}`}> <div className="grid gap-1"> <p className="font-medium">{title}</p> <p className="text-sm text-muted-foreground">{context}</p> </div> {children} </section> ); } const ORDER: IssueStatus[] = ["backlog", "todo", "in-progress", "in-review", "done", "cancelled"]; export function StatusIconDemo() { const [issues, setIssues] = useState([ { id: "CAL-212", title: "Billing page shows old plan name", status: "in-progress" as IssueStatus, progress: 0.4 }, { id: "CAL-208", title: "Export usage as CSV", status: "in-review" as IssueStatus, progress: 0.75 }, { id: "CAL-199", title: "Invite flow loses the role on retry", status: "todo" as IssueStatus, progress: 0.5 }, { id: "CAL-187", title: "Dark mode chart gridlines", status: "done" as IssueStatus, progress: 1 }, { id: "CAL-176", title: "Weekly digest email", status: "backlog" as IssueStatus, progress: 0.5 }, { id: "CAL-160", title: "Legacy webhooks v1", status: "cancelled" as IssueStatus, progress: 0.5 }, ]); return ( <Panel title="Cycle 14" context="Press a status to move the issue along." className="max-w-lg"> <ul className="grid"> {issues.map((issue, index) => ( <li key={issue.id} className="flex items-center gap-3 border-b border-border py-2.5 text-sm last:border-0"> <button type="button" className="grid size-8 place-items-center rounded-full outline-none hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring" aria-label={`Change status of ${issue.id}`} onClick={() => setIssues((list) => list.map((item, i) => (i === index ? { ...item, status: ORDER[(ORDER.indexOf(item.status) + 1) % ORDER.length]! } : item)), ) } > <StatusIcon status={issue.status} progress={issue.progress} /> </button> <span className="w-16 font-mono text-xs text-muted-foreground">{issue.id}</span> <span className="min-w-0 flex-1 truncate">{issue.title}</span> </li> ))} </ul> </Panel> ); }
API reference
StatusIcon
component
Also accepts the native props of its root element and a ref.
Accessibility
It is built on native elements, so roles and focus come from the browser.
No custom key handling; Tab and the native activation keys do the work.
Motion
Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.