Status Pill
A task status pill (Backlog → Todo → In progress → Done) with a ring icon that fills by stage and a quick menu.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/status-pill.json'use client'
import { useEffect, useRef, useState } from 'react'
import './status-pill.css'
export type Status = 'backlog' | 'todo' | 'progress' | 'review' | 'done'
export interface StatusPillProps {
defaultValue?: Status
onValueChange?: (s: Status) => void
showLabel?: boolean
fill?: 'dark' | 'light'
className?: string
}
const ORDER: Status[] = ['backlog', 'todo', 'progress', 'review', 'done']
const LABEL: Record<Status, string> = { backlog: 'Backlog', todo: 'Todo', progress: 'In progress', review: 'In review', done: 'Done' }
function Icon({ s }: { s: Status }) {
const f = ORDER.indexOf(s) / 4
if (s === 'done') return <svg className="sd-stp2__ic" viewBox="0 0 16 16" aria-hidden><circle cx="8" cy="8" r="7" fill="currentColor" style={{ fill: 'currentColor' }} /><path d="m5 8.3 2 2 4-4.3" stroke="var(--bg)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
const a = f * 2 * Math.PI, x = 8 + 4 * Math.sin(a), y = 8 - 4 * Math.cos(a)
return (
<svg className="sd-stp2__ic" viewBox="0 0 16 16" aria-hidden>
<circle cx="8" cy="8" r="7" strokeDasharray={s === 'backlog' ? '2 2.4' : undefined} />
{f > 0 && <path className="pie" d={`M8 8V4A4 4 0 ${f > 0.5 ? 1 : 0} 1 ${x.toFixed(2)} ${y.toFixed(2)}Z`} />}
</svg>
)
}
/** Workflow status pill with quick menu. */
export function StatusPill({ defaultValue = 'progress', onValueChange, showLabel = true, fill = 'dark', className }: StatusPillProps) {
const [s, setS] = useState<Status>(defaultValue)
const [open, setOpen] = useState(false)
const trig = useRef<HTMLButtonElement>(null)
const items = useRef<(HTMLButtonElement | null)[]>([])
useEffect(() => { if (open) items.current[ORDER.indexOf(s)]?.focus() }, [open, s])
const set = (n: Status) => { setS(n); onValueChange?.(n) }
return (
<div className={['sd-stp2', className].filter(Boolean).join(' ')} data-fill={fill} onKeyDown={(e) => { if (e.key === 'Escape' && open) { setOpen(false); trig.current?.focus() } }}>
<button
ref={trig}
type="button"
className="sd-stp2__trig"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Status: ${LABEL[s]}`}
onClick={() => setOpen(!open)}
onKeyDown={(e) => { const n = Number(e.key); if (n >= 1 && n <= 5) { e.preventDefault(); set(ORDER[n - 1]) } }}
>
<Icon s={s} />{showLabel && <span className="sd-stp2__lab"><span key={s}>{LABEL[s]}</span></span>}
</button>
{open && (
<div className="sd-stp2__pop" role="menu" aria-label="Set status">
{ORDER.map((o, i) => (
<button
key={o}
ref={(el) => { items.current[i] = el }}
type="button"
role="menuitemradio"
aria-checked={s === o}
className="sd-stp2__it"
onClick={() => { set(o); setOpen(false); trig.current?.focus() }}
onKeyDown={(e) => { const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; if (d) { e.preventDefault(); items.current[(i + d + 5) % 5]?.focus() } }}
>
<Icon s={o} />{LABEL[o]}<kbd>{i + 1}</kbd>
</button>
))}
</div>
)}
</div>
)
}.sd-stp2 { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-stp2[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-stp2 button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-stp2 :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-stp2 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-stp2 { position: relative; display: inline-block; align-self: start; margin-top: 50px; }
.sd-stp2__trig { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 11px 0 8px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; font-weight: 500; background: var(--bg) !important; }
.sd-stp2__trig:hover, .sd-stp2__trig[aria-expanded='true'] { box-shadow: inset 0 0 0 1px var(--mute); }
.sd-stp2__ic { width: 16px; height: 16px; flex: none; }
.sd-stp2__ic circle { fill: none; stroke: currentColor; stroke-width: 1.6; }
.sd-stp2__ic .pie { stroke: none; fill: currentColor; transition: d 300ms; }
.sd-stp2__lab span { display: inline-block; animation: sd-stp2-roll 220ms ease; }
@keyframes sd-stp2-roll { from { transform: translateY(5px); opacity: 0; } }
.sd-stp2__pop { position: absolute; top: calc(100% + 6px); left: 0; min-width: 220px; padding: 5px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -16px rgb(0 0 0 / 0.55); z-index: 4; animation: sd-stp2-in 160ms ease; }
@keyframes sd-stp2-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-stp2__pop { min-width: 190px; }
.sd-stp2__it { display: flex; align-items: center; gap: 9px; width: 100%; height: 34px; padding: 0 9px; border-radius: 8px; font-size: 13px; text-align: left; outline: 0; }
.sd-stp2__it:hover, .sd-stp2__it:focus-visible { background: var(--soft) !important; }
.sd-stp2__it kbd { margin-left: auto; font: 11px/1 ui-monospace, monospace; color: var(--mute); }
.sd-stp2__it[aria-checked='true'] { font-weight: 650; }
@media (prefers-reduced-motion: reduce) { .sd-stp2 *, .sd-stp2 *::before, .sd-stp2 *::after { animation: none !important; transition: none !important; } }Overview
The icon is a small ring that fills a quarter per stage, so status reads without colour: empty dashed for Backlog, half for In progress, a tick for Done.
Click to open a menu, or press 1–5 while it is focused to jump straight to a status.
When to use
- Issue trackers, task lists, content workflows.
When not to use
- Binary done / not done: use a checkbox.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/status-pill.json$
npx shadcn@latest add https://sundaeui.dev/r/status-pill.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/status-pill.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/status-pill.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy status-pill.tsx and status-pill.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { StatusPill } from '@/components/sundae/status-pill'
<StatusPill />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValueCurrent status. | Status | — |
onValueChangeCalled on change. | (s: Status) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A menu button; items are menuitemradio with the current one checked.
- Number keys 1–5 set status directly.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-stp2; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- 1–5
- Set status
- ↑ / ↓
- Move in menu
FAQ
- Is Status Pill free?
- Yes. Status Pill is free for personal and commercial projects, with full source on this page.
- How do I install Status Pill?
- Run npx shadcn@latest add https://sundaeui.dev/r/status-pill.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Status Pill support keyboard and screen readers?
- A menu button; items are menuitemradio with the current one checked. Number keys 1–5 set status directly.
- Does Status Pill respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Multi Select
Forms · RefinedStatus Picker
Forms · RefinedStandup Notes
Forms · RefinedBug Report
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined