Status Select
Inputs & Forms

Status Select

Linear-style workflow status picker with hand-drawn state glyphs, a progress arc that sweeps on open, grouped instant filtering, and number quick-select.

Install

npx shadcn@latest add @paragon/status-select

status-select.tsx

"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>
  );
}