Status Dropdown Select – Status and Priority Picker where the Chosen Dot Flies to the Trigger
Forms
Status Dropdown Select for React and Tailwind CSS (shadcn/ui): a status picker whose chosen dot, icon or photo flies from the list to the trigger, tinting the pill and rolling the label on landing. It is built to make a change in a row readable at a glance. Portal listbox with groups, shortcuts, type-ahead and arrow keys; opens upward when space is short. Controlled or uncontrolled, dot, icon or avatar kinds, full ARIA, reduced motion, light and dark. Use it for issue status, priority, assignees and orders. The included demo plays pointer and keyboard scenes, stops when you interact, uses embedded headshots and makes no network requests. Requires React only, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Status Dropdown Select – Status and Priority Picker where the Chosen Dot Flies to the Trigger to your project and fits it to your theme.
Code
How Status Dropdown Select – Status and Priority Picker where the Chosen Dot Flies to the Trigger is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { Circle } from "lucide-react";
import { StatusDropdownSelect } from "@/components/ui/status-dropdown-select";
import type { StatusOption } from "@/components/ui/status-dropdown-select";
type DemoProps = { autoplay?: boolean };
type Field = "status" | "priority" | "assignee";
type Values = { status: string; priority: string; assignee: string };
type Issue = { id: string; title: string; values: Values };
type Step = { at: number; run: () => void };
const CYCLE = 15000;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const HEADSHOTS = {
"photo-1629425733761-caae3b5f2e50": "data:image/webp;base64,UklGRhICAABXRUJQVlA4IAYCAABwDACdASpAAEAAPpVCmUklo6IzKA1SYBKJZQDLxYUI0IIpbsEhWKwvK+bZQRyWmbcFAlgNCrFe0mtXrvxKWXzH0NFpWm6POCt4qcM2VKToDThouqsoQ1e81VfATV2OeXmi9dWUJ918wXJGHwAA/vjjMegUAFhzfI7XhpxbM8WqhWANDdmNq/KXRTjavRvrIUg8TV/aYHfCC7sM2ABzAws+HJGJCo8hwvm0Z0L6f9eY+4AzueEkawmSGLed1vy+TY8SL/Iws3OZu/FFff4gK0pf0cIfILZXrgEq2MoP3YE7vLq8UExOJOj9AtZR/WZzIWZGhlnoJ+/n+XFYSxW33RwAFoo+BGSjse7i/m+9Mi6/UcJg7X4eqzyt7K9OUXsA4XRZT1Sy3Y28CBy042E954aV9BuV1MZViZgQJKakc2Poe1VK4FWNpp6XEUtq7cmy7OpGdF0u64SqUIfB5FIPKa9iGWWrl7X5jgucid/47OX9RnSpEgTcXODGRn+kcZJuPNJ5QBZw4OXU9Vpimk8IomET23YwGYGMALh9lB/psFVb/+t2jdEmIbt3yjOrmiWG71eOw26Uw/AVWdpQr333DeWe7CbZAWqprGkib+1aCIxpJD1VufJH9ZhVI9L/v/+g4knWtW9R35u8JnXi39c00puRfKBRYzm517r0kDKmufgIhHY4TgAAAA==",
"photo-1500648767791-00dcc994a43e": "data:image/webp;base64,UklGRvQCAABXRUJQVlA4IOgCAADwDwCdASpAAEAAPpVAnUsloyKhphVbELASiUAXZnvpai7hU0a/nH9rAR2YM+FKTrqb6A4BM2/7MHyv6YvL/G30XZAcLqXdWT1ug8uMLFJTFoctXTqvy5SmGgfmFPy3ihxkvbWBEs1Dkpd9nrkrHdDCvmZDp3AzjIsmMVnyg2RUliB6LNtTE/3YAOBrPztLBRVN7O3+WtPwLbhRj/yMoXmvfK0TH3TlD8T4sbolRZTKtA7xkJawL8AdmibGiHxAYaJPq3ydzGv3A6O2/QDdWTU2q6oI4LLgzckyXfAWnHvkVkL99bhSSePjMbqgIg1tuc4PwiExMS0szVEWVMPR7RnjkJqzL4VzsA/bekXUA6ibtoxEXWfwwAl1lwpUflg82DD0D1irYb29B5ZmziQ5izKSe+m76wfUV429a3nKEmbMBHeOGWsa1Nh6bEIl7OuU1kl87O7xHhspysTN48fKJLfv2H6BBo6bk56xeJHX73vxA/PqJ1jZVlP9fmYhQYn281Y20XVQqvgVidO3IFilR6qldyJ1k2xZV6s3qJNlNGzaZoCj1K1m8P4+tHLh7fICq+K/0jd9Pb8ghhrNSHmwrfKKSETn5EL54b+6BrnT1RS1TJW72Eh9bdXDRzjeZ/hNaDWOsg/XB7ltjcF4yMj6ksAczvB4ke+YYFxVq+RdqoSO85uN1bA/rD82eH+AD5tn6sCKMfQHro417s1P77/opZHr8w95CH2oLgyKyBgIffs25u2QMHHReEmYE4oJkMXml2hKbJsTVSiFvGZwRj9Nz+riCFzNiGUWBoZzK4f7zCakpxpmt7JTQ3UNqLfT0mfmFMJvZs/82vZLhchcwHrJYfNjhFagG0zHgANX+Gf+EGDNdwLWYAo5B3LcEOz1mdFj1zE7dp0Jq/lKBpbYQnZOQPbkDN0m+7hmAnQ3U6PM3VxxwocWnxht0m8meNgmZrIbDTKxG+durlKDPPgsWBsBmCTPrUcgx1FQAAA=",
"photo-1543949806-2c9935e6aa78": "data:image/webp;base64,UklGRkQCAABXRUJQVlA4IDgCAABwDgCdASpAAEAAPpVAmUglpCKhLhkriLASiWMAxnQggi2/qxXnooxCNEwBPuc1gPFoAdJbx7xhGVuePxDttYAgrYIrZzVyFmyD0fcSFXCgl2zH4KddnNWP3k8Cj10NAw723HsEo1V2pad/e2VxF8V4AXxJu19VxeUpIUDcAP77bM8qF2hm8FssDig04SVeeed+Jco5+4PmbknTn+bBAph49uNOllQUUgrfUfLhk/BqJGAz+kCZiA5yDtqPgBQ9t/E4rJpZsNKxgqz/UCdrJgxItVO60nGWnVQZZWFOlCwqUr9W23okeM2NHyj1/+jfEaqgsE0yMvrGhqNpaWOJTfVVHmoNqrtjVTjJp2xAAFxwuBRGOhGPn0owFJxBUCDLA5sfyDpIrxedRD+hclzKmxgf8XzJYxaMsziFCHCLXMHVzL017HLl7dJUdt4UF70a0XvBR6feJkelmMuP5ngMhkH21cHlVW2wND/9u6cvPIadCZJyyNwu5ICgEXKMTCmG3LcIvbNHLOgN8aIkZaS+Snivf3bY2u/Cr2YWVkmDTJ4nQ4sRl5JpwdMPFf0/OxmRGvdutZfc6vEJ8uLylTsktG2qa/gYmdAAu/n9LyLL1OjYy4wsrPzC0+34k6SK/rm/lvNvBopnNxMfJbbSA0XBJn5R1wjMn2spN3Zi0d6EBkuxFOksJgzpzSSFC0JyXShTjggTeSDYrvdPHF3eoUTD53Bfkt9o9Na/TudGcHgdAFO4/D5TrNlXYAAA",
"photo-1627161683077-e34782c24d81": "data:image/webp;base64,UklGRqoCAABXRUJQVlA4IJ4CAACQDQCdASpAAEAAPpVCm0ilpCKhKBgNULASiUAYea9+HR9L383Ye1mW9p7Jo3mR/87OV9urYuzD1MMRd122Yc9VXR4MaCcMOiI6qSnT5JoXKCN5RDQyD9Ta1A/QCTR+u+2QCZ3MTttpoC2Bfx4vSGPpYZgsIAAA/vvLhO82lB4CB+7VN4snDhzgoLXOO0NOwfOYQBKrgguPImDT1k/zcbbdpoh3mtF9oZYEKGYkv26rfmnAl7b8zcGNZwTJy9fq3LH97otdnxgy6VUJQYxgrjxXOrTOBOE3KI5hzSDMr0AkVjZOdCP4Cw2Kzd8n4UVMJPghC+R5Bw5S1Gg7lYFTR18xMJlWLL9gjXvleiMjysB6KzqmhdhyHyHfmXMhBzLqtXyVUs0+nGg+yFGRvcn5GPBMWJDSwkbuKourC9VavS8ckCv5NQSPg89irOiFWBIAJQhy3KYGku+0J3bnGtMRyp0F1F00QVhYTSaD/2EroV3aNZ072CqprEg737B0GWAA/88Toy4RQ3dH6kfJW9sLHZydA2/8Vgb1rbrfHGYI5uISjQd8XskjwtVxLfSK1nisFtDYA/eeaN56rcM0MztXTeQqGH0OVJxFSqoy8bCp1KxfKZaMv7mfHTBnIvC8k4bFT+tqCpfmVz/mI5B2l6YlyRr8BZuFzJFT+VS3sWQMpZ/WzryG0YSyphVrmne6BGns/tESVvUuar9u+4qCvWTrU5emz+icfeUeXUJCC1jqezUqPUoR5ccfc1RlYX1oVxO9HQlPlP0vkw97NsB9aV4yrfHZvqkdUiWOFRT0ek5/WtdCeD1U5PGYyy9i8Q7BpbDmSH5+yV67qZUwgrtQheiGRuyJiHdpMvvtC0zox1d/t29YnpBOGF25ra6YmV+rjxAA",
} as const;
function Bars({ level, urgent = false }: { level: 0 | 1 | 2 | 3 | 4; urgent?: boolean }) {
return (
<span aria-hidden="true" className="flex size-3.5 items-end justify-center gap-px">
{[1, 2, 3, 4].map((bar) => (
<span
key={bar}
className={`w-[2px] rounded-[1px] ${bar <= level ? (urgent ? "bg-destructive" : "bg-foreground") : "bg-muted-foreground/30"}`}
style={{ height: `${4 + (bar - 1) * 3}px` }}
/>
))}
</span>
);
}
const STATUS: StatusOption[] = [
{ value: "backlog", label: "Backlog", color: "muted", group: "Planned", shortcut: "1" },
{ value: "todo", label: "Todo", color: "chart-4", group: "Planned", shortcut: "2" },
{ value: "in_progress", label: "In progress", color: "chart-1", group: "Started", shortcut: "3" },
{ value: "in_review", label: "In review", color: "chart-3", group: "Started", shortcut: "4" },
{ value: "done", label: "Done", color: "chart-2", group: "Closed", shortcut: "5" },
{ value: "canceled", label: "Canceled", color: "destructive", group: "Closed", shortcut: "6" },
];
const PRIORITY: StatusOption[] = [
{ value: "none", label: "No priority", color: "muted", icon: <Bars level={0} />, shortcut: "0" },
{ value: "low", label: "Low", icon: <Bars level={1} />, shortcut: "1" },
{ value: "medium", label: "Medium", icon: <Bars level={2} />, shortcut: "2" },
{ value: "high", label: "High", icon: <Bars level={3} />, shortcut: "3" },
{ value: "urgent", label: "Urgent", color: "destructive", icon: <Bars level={4} urgent />, shortcut: "4" },
];
const ASSIGNEE: StatusOption[] = [
{ value: "unassigned", label: "Unassigned", color: "muted" },
{ value: "jordan", label: "Jordan Lee", avatarUrl: HEADSHOTS["photo-1629425733761-caae3b5f2e50"] },
{ value: "mina", label: "Mina Park", avatarUrl: HEADSHOTS["photo-1500648767791-00dcc994a43e"] },
{ value: "theo", label: "Theo Alvarez", avatarUrl: HEADSHOTS["photo-1543949806-2c9935e6aa78"] },
{ value: "sora", label: "Sora Kim", avatarUrl: HEADSHOTS["photo-1627161683077-e34782c24d81"] },
];
const ISSUES: Issue[] = [
{ id: "ALD-248", title: "Fix checkout latency on mobile", values: { status: "todo", priority: "none", assignee: "unassigned" } },
{ id: "ALD-251", title: "Add passkey sign-in to the web app", values: { status: "backlog", priority: "none", assignee: "unassigned" } },
];
const FIELDS: { key: Field; label: string; options: StatusOption[]; kind: "dot" | "icon" | "avatar" }[] = [
{ key: "status", label: "Status", options: STATUS, kind: "dot" },
{ key: "priority", label: "Priority", options: PRIORITY, kind: "icon" },
{ key: "assignee", label: "Assignee", options: ASSIGNEE, kind: "avatar" },
];
export default function Demo({ autoplay = true }: DemoProps) {
const params = React.useMemo(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location.search), []);
const controlled = params.get("controlled") === "1";
const uncontrolled = params.get("uncontrolled") === "1";
const fly = params.get("fly") !== "0";
const size = params.get("size") === "sm" ? "sm" : "md";
const align = params.get("align") === "end" ? "end" : "start";
const first = ISSUES[0]!;
const [issueIndex, setIssueIndex] = React.useState(0);
const [values, setValues] = React.useState(first.values);
const [openField, setOpenField] = React.useState(null as Field | null);
const [requests, setRequests] = React.useState([] as string[]);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const [paused, setPaused] = React.useState(false);
const [run, setRun] = React.useState(0);
const rootRef = React.useRef(null as HTMLElement | null);
const cardRef = React.useRef(null as HTMLDivElement | null);
const titleRef = React.useRef(null as HTMLDivElement | null);
const statusRef = React.useRef(null as HTMLDivElement | null);
const scripted = autoplay && !controlled && !uncontrolled && !paused;
const issue = ISSUES[issueIndex] ?? first;
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready || motion.reduced) return;
const card = cardRef.current;
if (card && typeof card.animate === "function") {
card.animate([{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 360, easing: EASE });
}
}, [motion.ready, motion.reduced]);
const pressKey = React.useCallback((key: string) => {
const trigger = statusRef.current?.querySelector("[data-trigger]");
const id = trigger?.getAttribute("aria-controls");
const listbox = id ? document.getElementById(id) : null;
listbox?.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
}, []);
const switchIssue = React.useCallback((index: number) => {
const next = ISSUES[index];
if (!next) return;
setIssueIndex(index);
setValues(next.values);
setOpenField(null);
const title = titleRef.current;
if (title && typeof title.animate === "function" && !motion.reduced) {
title.animate([{ opacity: 0, transform: "translateY(4px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 260, easing: EASE });
}
}, [motion.reduced]);
React.useEffect(() => {
if (!motion.ready || motion.reduced || !scripted) return;
const root = rootRef.current;
let count = 0;
if (root) { root.dataset.tourStarted = performance.now().toFixed(1); root.dataset.tourStep = "0"; }
const pick = (field: Field, value: string) => { setValues((current) => ({ ...current, [field]: value })); setOpenField(null); };
const script: Step[] = [
{ at: 500, run: () => setOpenField("status") },
{ at: 1500, run: () => pick("status", "in_progress") },
{ at: 3200, run: () => setOpenField("priority") },
{ at: 4000, run: () => pick("priority", "high") },
{ at: 4700, run: () => setOpenField("assignee") },
{ at: 5400, run: () => pick("assignee", "mina") },
{ at: 7200, run: () => switchIssue(1) },
{ at: 7900, run: () => setOpenField("status") },
{ at: 8500, run: () => pressKey("ArrowDown") },
{ at: 8900, run: () => pressKey("ArrowDown") },
{ at: 9500, run: () => pressKey("Enter") },
{ at: 10800, run: () => setOpenField("priority") },
{ at: 11600, run: () => pick("priority", "urgent") },
{ at: 12300, run: () => setOpenField("assignee") },
{ at: 13000, run: () => pick("assignee", "theo") },
{ at: 14600, run: () => switchIssue(0) },
];
const timers = script.map((step) => window.setTimeout(() => {
step.run();
count += 1;
if (root) { root.dataset.tourStep = String(count); root.dataset.tourLastAt = performance.now().toFixed(1); }
}, step.at));
timers.push(window.setTimeout(() => setRun((previous) => previous + 1), CYCLE));
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [motion.ready, motion.reduced, scripted, run, pressKey, switchIssue]);
const takeOver = (event: React.SyntheticEvent) => { if (scripted && event.nativeEvent.isTrusted) setPaused(true); };
const note = (entry: string) => setRequests((previous) => [...previous, entry]);
return (
<main
ref={rootRef}
data-demo="status-dropdown-select"
data-issue={issue.id}
className="relative flex w-full items-center justify-center bg-background p-4 text-foreground"
style={{ height: "min(100svh, 760px)" }}
onPointerDownCapture={takeOver}
onKeyDownCapture={takeOver}
>
<div ref={cardRef} data-issue-card className="w-full max-w-[720px] rounded-2xl bg-card p-3 text-card-foreground shadow-sm shadow-foreground/5 ring-1 ring-border sm:p-3.5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div ref={titleRef} className="flex min-w-0 flex-1 items-center gap-2.5">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground"><Circle className="size-4" strokeWidth={1.75} aria-hidden="true" /></span>
<span data-issue-id className="shrink-0 font-mono text-xs text-muted-foreground">{issue.id}</span>
<span data-issue-title className="min-w-0 truncate text-sm font-medium">{issue.title}</span>
</div>
<div className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 sm:flex sm:shrink-0 sm:items-center sm:gap-2">
{FIELDS.map((field) => (
<React.Fragment key={field.key}>
<span className="text-xs text-muted-foreground sm:sr-only">{field.label}</span>
<StatusDropdownSelect
ref={field.key === "status" ? statusRef : undefined}
data-field={field.key}
className="justify-self-start"
options={field.options}
kind={field.kind}
label={field.label}
size={size}
fly={fly}
align={align}
menuWidth={field.kind === "avatar" ? 232 : 224}
{...(uncontrolled
? { defaultValue: issue.values[field.key] }
: controlled
? { value: first.values[field.key], onValueChange: (next: string) => note(`${field.key}:${next}`) }
: { value: values[field.key], onValueChange: (next: string) => setValues((current) => ({ ...current, [field.key]: next })) })}
{...(uncontrolled || controlled
? {}
: { open: openField === field.key, onOpenChange: (next: boolean) => setOpenField((current) => (next ? field.key : current === field.key ? null : current)) })}
/>
</React.Fragment>
))}
</div>
</div>
</div>
{controlled && <output data-requests className="sr-only">{requests.join(" ")}</output>}
</main>
);
}
















