Linear-style workflow status picker with hand-drawn state glyphs, a progress arc that sweeps on open, grouped instant filtering, and number quick-select.
npx shadcn@latest add @paragon/status-select"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
export type StatusKind =
| "backlog"
| "unstarted"
| "started"
| "completed"
| "canceled";
export interface StatusOption {
id: string;
label: string;
kind: StatusKind;
/** Fill fraction (0–1) for `started` glyphs. */
progress?: number;
}
const DEFAULT_STATUSES: StatusOption[] = [
{ id: "backlog", label: "Backlog", kind: "backlog" },
{ id: "todo", label: "Todo", kind: "unstarted" },
{ id: "in-progress", label: "In Progress", kind: "started", progress: 0.5 },
{ id: "in-review", label: "In Review", kind: "started", progress: 0.8 },
{ id: "done", label: "Done", kind: "completed" },
{ id: "canceled", label: "Canceled", kind: "canceled" },
];
const GROUP_LABELS: Record<StatusKind, string> = {
backlog: "Backlog",
unstarted: "Unstarted",
started: "Started",
completed: "Completed",
canceled: "Canceled",
};
const GROUP_ORDER: StatusKind[] = [
"backlog",
"unstarted",
"started",
"completed",
"canceled",
];
const KIND_COLOR: Record<StatusKind, string> = {
backlog: "text-muted-foreground/80",
unstarted: "text-muted-foreground",
started: "text-warning",
completed: "text-success",
canceled: "text-muted-foreground",
};
/** Inner pie radius — dasharray math depends on it. */
const PIE_R = 1.75;
const PIE_C = 2 * Math.PI * PIE_R;
/**
* Linear-style workflow state glyph. `started` draws a pie arc whose
* stroke-dashoffset transitions — on list open it sweeps in from empty,
* and while the row is highlighted it eagerly arcs a step further.
*/
export function StatusGlyph({
kind,
progress = 0.5,
eager = false,
sweepIn = false,
className,
...props
}: React.ComponentProps<"svg"> & {
kind: StatusKind;
progress?: number;
/** Extend the arc a step further (highlighted row preview). */
eager?: boolean;
/** Animate the arc from empty on mount. */
sweepIn?: boolean;
}) {
const frac = Math.min(1, Math.max(0, progress));
const shown = eager ? Math.min(1, frac + 0.25) : frac;
return (
<svg
viewBox="0 0 14 14"
className={cn("size-3.5 shrink-0", KIND_COLOR[kind], className)}
aria-hidden
{...props}
>
{kind === "backlog" && (
<circle
cx="7"
cy="7"
r="5.25"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray="1.6 1.9"
strokeLinecap="round"
/>
)}
{kind === "unstarted" && (
<circle cx="7" cy="7" r="5.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
)}
{kind === "started" && (
<>
<circle cx="7" cy="7" r="5.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
<circle
cx="7"
cy="7"
r={PIE_R}
fill="none"
stroke="currentColor"
strokeWidth={PIE_R * 2}
strokeDasharray={`${PIE_C} ${PIE_C}`}
strokeDashoffset={PIE_C * (1 - shown)}
transform="rotate(-90 7 7)"
className={cn(
"transition-[stroke-dashoffset] duration-200 ease-(--ease-out) motion-reduce:transition-none",
sweepIn && "animate-[pg-status-sweep_350ms_var(--ease-out)]",
)}
/>
</>
)}
{kind === "completed" && (
<>
<circle cx="7" cy="7" r="6" fill="currentColor" />
<path
d="M4.4 7.3 6.2 9.1 9.6 5.4"
fill="none"
stroke="var(--color-background)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
{kind === "canceled" && (
<>
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.35" />
<path
d="M4.9 4.9 9.1 9.1 M9.1 4.9 4.9 9.1"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</>
)}
</svg>
);
}
const statusSelectStyles = `
@keyframes pg-status-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-status-out { to { opacity: 0; scale: 0.99; } }
@keyframes pg-status-sweep { from { stroke-dashoffset: ${PIE_C}; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-status-in { from { opacity: 0; } }
@keyframes pg-status-out { to { opacity: 0; } }
@keyframes pg-status-sweep { from { stroke-dashoffset: 0; } }
}
`;
export interface StatusSelectProps
extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
> {
statuses?: StatusOption[];
/** Controlled selected status id. */
value?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
/** Hide the label on the trigger (glyph-only chip). */
showLabel?: boolean;
size?: "sm" | "md";
/** Disables trigger swap + arc motion. */
static?: boolean;
}
/**
* Linear-grade status picker: grouped workflow states with hand-drawn SVG
* state glyphs (dashed backlog ring, half-fill arc that sweeps on open and
* arcs further on hover, check-pop done), instant filtering, arrow-key
* navigation and number quick-select. The trigger blur-swaps to the new
* state on select.
*/
export function StatusSelect({
statuses = DEFAULT_STATUSES,
value,
defaultValue,
onValueChange,
showLabel = true,
size = "md",
static: isStatic = false,
className,
disabled,
...props
}: StatusSelectProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [internal, setInternal] = React.useState(
defaultValue ?? statuses[0]?.id ?? "",
);
const selectedId = value ?? internal;
const selected = statuses.find((s) => s.id === selectedId) ?? statuses[0];
const listRef = React.useRef<HTMLDivElement>(null);
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return q
? statuses.filter((s) => s.label.toLowerCase().includes(q))
: statuses;
}, [statuses, query]);
const groups = React.useMemo(() => {
return GROUP_ORDER.map((kind) => ({
kind,
items: visible.filter((s) => s.kind === kind),
})).filter((g) => g.items.length > 0);
}, [visible]);
const [activeId, setActiveId] = React.useState(selectedId);
const activeIndex = visible.findIndex((s) => s.id === activeId);
React.useEffect(() => {
if (!open) return;
listRef.current
?.querySelector('[data-active="true"]')
?.scrollIntoView({ block: "nearest" });
}, [activeId, open]);
const select = (id: string) => {
setInternal(id);
onValueChange?.(id);
setOpen(false);
};
const move = (delta: number) => {
if (visible.length === 0) return;
const next =
(Math.max(0, activeIndex) + delta + visible.length) % visible.length;
setActiveId(visible[next].id);
};
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Home" && query === "") {
e.preventDefault();
if (visible[0]) setActiveId(visible[0].id);
} else if (e.key === "End" && query === "") {
e.preventDefault();
if (visible.length) setActiveId(visible[visible.length - 1].id);
} else if (e.key === "Enter") {
e.preventDefault();
const target = visible[activeIndex] ?? visible[0];
if (target) select(target.id);
} else if (/^[1-9]$/.test(e.key) && query === "") {
// Number quick-select follows the visible (grouped) order.
e.preventDefault();
const target = visible[Number(e.key) - 1];
if (target) select(target.id);
}
};
const animate = !isStatic && !reduced;
const blur = animate ? "blur(4px)" : "blur(0px)";
let rowNumber = 0;
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuery("");
setActiveId(selectedId);
}
}}
>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<motion.button
type="button"
layout={animate}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
data-slot="status-select"
aria-label={`Status: ${selected?.label ?? "none"}`}
title={showLabel ? undefined : selected?.label}
className={cn(
"group inline-flex items-center gap-1.5 rounded-md border border-input bg-transparent font-medium whitespace-nowrap",
size === "sm" ? "h-6 px-1.5 text-[11px]" : "h-7 px-2 text-xs",
"transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--ease-out)",
"outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"data-[state=open]:border-ring disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.97]",
className,
)}
{...props}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selected?.id ?? "none"}
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: blur }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: blur }}
transition={{ type: "spring", duration: 0.3, bounce: 0.15 }}
>
<StatusGlyph kind={selected?.kind ?? "unstarted"} progress={selected?.progress} />
</motion.span>
</AnimatePresence>
{showLabel && (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selected?.id ?? "none"}
layout={animate ? "position" : false}
initial={{ opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: blur, transition: { duration: 0.1 } }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
{selected?.label ?? "Set status"}
</motion.span>
</AnimatePresence>
)}
</motion.button>
</PopoverPrimitive.Trigger>
{/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
child node, and the Radix Portal enforces a single child. */}
<style href="paragon-status-select" precedence="paragon">
{statusSelectStyles}
</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="start"
sideOffset={6}
collisionPadding={8}
className={cn(
"z-50 w-56 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover text-popover-foreground shadow-overlay outline-none",
"data-[state=open]:animate-[pg-status-in_170ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-status-out_90ms_var(--ease-exit)_forwards]",
)}
>
<div className="flex items-center gap-2 border-b border-border px-2.5">
<Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<input
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveId("");
}}
onKeyDown={onSearchKeyDown}
placeholder="Change status…"
role="combobox"
aria-expanded="true"
aria-controls={`status-list-${uid}`}
aria-activedescendant={
activeIndex >= 0 ? `status-opt-${uid}-${activeId}` : undefined
}
aria-label="Filter statuses"
className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
id={`status-list-${uid}`}
role="listbox"
aria-label="Statuses"
className="max-h-72 overflow-y-auto p-1"
onPointerLeave={() => setActiveId("")}
>
{visible.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
No matching status.
</p>
)}
{groups.map((group) => (
<React.Fragment key={group.kind}>
<p className="px-2 pt-1.5 pb-1 text-[10px] font-medium tracking-wide text-muted-foreground/80 uppercase select-none">
{GROUP_LABELS[group.kind]}
</p>
{group.items.map((option) => {
rowNumber += 1;
const n = rowNumber;
const isActive = option.id === activeId;
const isSelected = option.id === selectedId;
return (
<button
key={option.id}
type="button"
role="option"
id={`status-opt-${uid}-${option.id}`}
aria-selected={isSelected}
data-active={isActive || undefined}
tabIndex={-1}
onPointerMove={() => setActiveId(option.id)}
onClick={() => select(option.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
"transition-[background-color] duration-100 ease-(--ease-out)",
isActive && "bg-accent text-accent-foreground",
)}
>
<StatusGlyph
kind={option.kind}
progress={option.progress}
eager={animate && isActive && option.kind === "started"}
sweepIn={animate && option.kind === "started"}
/>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
<span className="flex size-3.5 shrink-0 items-center justify-center">
{isSelected && <Check className="size-3.5" aria-hidden />}
</span>
{n <= 9 && (
<span
aria-hidden
className="w-3 shrink-0 text-right font-mono text-[10px] text-muted-foreground/70 tabular-nums"
>
{n}
</span>
)}
</button>
);
})}
</React.Fragment>
))}
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}