DHxWhy

Status Dropdown Select – Status and Priority Picker where the Chosen Dot Flies to the Trigger

Forms

About

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

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    883
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    830
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    431
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    328
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    268
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    243
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    217

More in Forms

View all
  • Premium Auth

    Premium Auth

    543
  • Contact Card

    Contact Card

    1.1K
  • Form

    Form

    17
  • Card Studio

    Card Studio

    46
  • Book A Demo 1

    Book A Demo 1

    80
  • Login 2

    Login 2

    10
  • Login 1

    Login 1

    58
  • SignInPage

    SignInPage

    710