# Table Inline Status Edit — React Table Component

> Each row carries a status pill that opens a keyboard-friendly dropdown in place; the pill swaps with a spring and the row confirms the save.

- Category: [Tables & Lists](https://pixelpapa.com/components/tables.md)
- Tags: table, status, dropdown, inline edit, listbox
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tables/table-inline-status-edit

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/table-inline-status-edit.json"
```

The file is written to `components/pixelpapa/table-inline-status-edit.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check, ChevronDown } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type StatusOption = {
  value: string;
  label: string;
  /** Tailwind classes for the dot and the pill surface. */
  dot: string;
  pill: string;
};

type StatusSelectProps = {
  value: string;
  options: StatusOption[];
  onChange: (value: string) => void;
  /** Accessible name, e.g. "Status for Fix webhook retries". */
  label: string;
  className?: string;
};

export function StatusSelect({ value, options, onChange, label, className }: StatusSelectProps) {
  const id = useId();
  const [open, setOpen] = useState(false);
  const [up, setUp] = useState(false);
  const root = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLDivElement>(null);
  const current = options.find((o) => o.value === value) ?? options[0];

  useEffect(() => {
    if (!open) return;
    list.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.focus();
    const away = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", away);
    return () => document.removeEventListener("pointerdown", away);
  }, [open]);

  function show() {
    const r = trigger.current?.getBoundingClientRect();
    // Flip upward when the menu would leave the viewport.
    setUp(!!r && r.bottom + options.length * 34 + 20 > window.innerHeight);
    setOpen(true);
  }

  function close(focus = true) {
    setOpen(false);
    if (focus) trigger.current?.focus();
  }

  function onListKey(e: KeyboardEvent<HTMLDivElement>) {
    const items = [...(list.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])];
    const i = items.indexOf(document.activeElement as HTMLElement);
    if (e.key === "ArrowDown") items[(i + 1) % items.length]?.focus();
    else if (e.key === "ArrowUp") items[(i - 1 + items.length) % items.length]?.focus();
    else if (e.key === "Home") items[0]?.focus();
    else if (e.key === "End") items[items.length - 1]?.focus();
    else if (e.key === "Escape" || e.key === "Tab") close();
    else return;
    e.preventDefault();
  }

  return (
    <div ref={root} className={cn("relative inline-block", className)}>
      <button
        ref={trigger}
        type="button"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={id}
        aria-label={`${label}: ${current.label}`}
        onClick={() => (open ? close() : show())}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown" || e.key === "ArrowUp") {
            e.preventDefault();
            show();
          }
        }}
        className={cn(
          "inline-flex h-7 items-center gap-1.5 overflow-hidden rounded-full pr-1.5 pl-2.5 text-xs font-medium transition-[background-color,box-shadow] outline-none hover:ring-2 hover:ring-current/15 focus-visible:ring-2 focus-visible:ring-ring/60",
          current.pill,
        )}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={current.value}
            initial={{ y: 10, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{ y: -10, opacity: 0 }}
            transition={{ type: "spring", stiffness: 500, damping: 32 }}
            className="inline-flex items-center gap-1.5"
          >
            <span className={cn("size-1.5 rounded-full", current.dot)} />
            {current.label}
          </motion.span>
        </AnimatePresence>
        <ChevronDown className={cn("size-3.5 opacity-60 transition-transform duration-200", open && "rotate-180")} />
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            ref={list}
            id={id}
            role="listbox"
            aria-label={label}
            onKeyDown={onListKey}
            initial={{ opacity: 0, scale: 0.94, y: up ? 6 : -6 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.1 } }}
            transition={{ type: "spring", stiffness: 520, damping: 32 }}
            className={cn(
              "absolute left-0 z-20 w-44 rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl shadow-black/10",
              up ? "bottom-full mb-1.5 origin-bottom-left" : "top-full mt-1.5 origin-top-left",
            )}
          >
            {options.map((o) => {
              const selected = o.value === value;
              return (
                <button
                  key={o.value}
                  type="button"
                  role="option"
                  aria-selected={selected}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => {
                    onChange(o.value);
                    close();
                  }}
                  className="flex h-[34px] w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent"
                >
                  <span className={cn("size-2 rounded-full", o.dot)} />
                  <span className="flex-1">{o.label}</span>
                  {selected && <Check className="size-3.5 text-primary" strokeWidth={2.5} />}
                </button>
              );
            })}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

const statuses: StatusOption[] = [
  { value: "backlog", label: "Backlog", dot: "bg-muted-foreground/60", pill: "bg-muted text-muted-foreground" },
  { value: "progress", label: "In progress", dot: "bg-blue-500", pill: "bg-blue-500/10 text-blue-700 dark:text-blue-400" },
  { value: "review", label: "In review", dot: "bg-violet-500", pill: "bg-violet-500/10 text-violet-700 dark:text-violet-400" },
  { value: "done", label: "Done", dot: "bg-emerald-500", pill: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400" },
  { value: "blocked", label: "Blocked", dot: "bg-red-500", pill: "bg-red-500/10 text-red-700 dark:text-red-400" },
];

type Task = { id: string; title: string; owner: string; hue: string; status: string; updated: string };

const seed: Task[] = [
  { id: "OPS-412", title: "Fix webhook retries after 502s", owner: "Ines Varga", hue: "bg-chart-1", status: "progress", updated: "12 min ago" },
  { id: "OPS-409", title: "Rotate staging database credentials", owner: "Jun Okabe", hue: "bg-chart-5", status: "review", updated: "1 hr ago" },
  { id: "OPS-405", title: "Audit log export to CSV", owner: "Mira Osei", hue: "bg-chart-3", status: "backlog", updated: "Yesterday" },
  { id: "OPS-398", title: "Rate-limit the public search API", owner: "Tomas Reyl", hue: "bg-chart-4", status: "blocked", updated: "Nov 09" },
  { id: "OPS-391", title: "Migrate cron jobs to the queue", owner: "Ines Varga", hue: "bg-chart-1", status: "done", updated: "Nov 07" },
];

export default function TableInlineStatusEditDemo() {
  const [tasks, setTasks] = useState(seed);
  const [saved, setSaved] = useState<string | null>(null);

  return (
    <div className="w-full max-w-2xl rounded-2xl border border-border bg-card shadow-sm">
      <table className="w-full border-separate border-spacing-0 text-sm">
        <caption className="sr-only">Operations tasks</caption>
        <thead>
          <tr className="text-left text-xs text-muted-foreground">
            <th scope="col" className="rounded-tl-2xl border-b border-border bg-muted/50 py-2.5 pr-3 pl-5 font-medium">Task</th>
            <th scope="col" className="border-b border-border bg-muted/50 px-3 py-2.5 font-medium">Owner</th>
            <th scope="col" className="border-b border-border bg-muted/50 px-3 py-2.5 font-medium">Status</th>
            <th scope="col" className="rounded-tr-2xl border-b border-border bg-muted/50 py-2.5 pr-5 pl-3 text-right font-medium">Updated</th>
          </tr>
        </thead>
        <tbody>
          {tasks.map((t) => (
            <tr key={t.id} className="group/row [&>td]:border-b [&>td]:border-border last:[&>td]:border-b-0">
              <td className="py-3 pr-3 pl-5">
                <p className="font-medium text-card-foreground">{t.title}</p>
                <p className="mt-0.5 font-mono text-[11px] text-muted-foreground">{t.id}</p>
              </td>
              <td className="px-3 py-3">
                <span className="flex items-center gap-2 whitespace-nowrap text-muted-foreground">
                  <span className={cn("flex size-6 items-center justify-center rounded-full text-[10px] font-semibold text-white", t.hue)}>
                    {t.owner.split(" ").map((w) => w[0]).join("")}
                  </span>
                  {t.owner}
                </span>
              </td>
              <td className="px-3 py-3">
                <StatusSelect
                  label={`Status for ${t.title}`}
                  value={t.status}
                  options={statuses}
                  onChange={(status) => {
                    setTasks((prev) => prev.map((x) => (x.id === t.id ? { ...x, status, updated: "Just now" } : x)));
                    setSaved(t.id);
                  }}
                />
              </td>
              <td className="py-3 pr-5 pl-3 text-right text-xs whitespace-nowrap text-muted-foreground">
                <AnimatePresence mode="wait" initial={false}>
                  <motion.span
                    key={t.updated}
                    initial={{ opacity: 0, y: 6 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: -6 }}
                    transition={{ duration: 0.18 }}
                    className={cn("inline-flex items-center gap-1", saved === t.id && t.updated === "Just now" && "text-emerald-600 dark:text-emerald-400")}
                  >
                    {t.updated === "Just now" && <Check className="size-3.5" strokeWidth={2.5} />}
                    {t.updated === "Just now" ? "Saved just now" : t.updated}
                  </motion.span>
                </AnimatePresence>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

## AI prompt

```text
Add the "Table Inline Status Edit" component from PixelPapa UI to this project.

What it is: Each row carries a status pill that opens a keyboard-friendly dropdown in place; the pill swaps with a spring and the row confirms the save.
Category: Tables & Lists. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/table-inline-status-edit.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/table-inline-status-edit.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/table-inline-status-edit" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tables & lists

- [List Drag Reorder](https://pixelpapa.com/components/tables/list-drag-reorder.md): A ranked list you reorder by dragging a grip handle (or Arrow keys); the lifted row floats with a shadow while the others spring out of its way.
- [Table Expandable Rows](https://pixelpapa.com/components/tables/table-expandable-rows.md) (Pro): Table rows that open a detail panel underneath with a spring height animation and a rotating chevron, one at a time or many.
- [Table Row Select Bulk](https://pixelpapa.com/components/tables/table-row-select-bulk.md) (Pro): Checkbox rows with shift-click range selection and a floating action bar that springs up from the bottom with a rolling count.
- [Table Sortable Animated](https://pixelpapa.com/components/tables/table-sortable-animated.md): A semantic data table whose rows glide to their new positions with spring layout animation when a column header is clicked.
