Actions Table
Original · freetable with row action menus
byWeb InnoventixReact + Tailwind
tableactionstables
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/table-actions.jsontable-actions.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "active" | "invited" | "suspended";
interface Member {
id: string;
name: string;
email: string;
role: string;
status: Status;
lastActive: string;
}
interface ActionItem {
key: string;
label: string;
icon: ReactNode;
onSelect: () => void;
destructive?: boolean;
}
interface ToastState {
id: number;
message: string;
undo?: () => void;
}
type SortKey = "name" | "role" | "status";
const INITIAL_MEMBERS: Member[] = [
{ id: "m-1", name: "Amara Okafor", email: "amara.okafor@northwind.io", role: "Owner", status: "active", lastActive: "2 minutes ago" },
{ id: "m-2", name: "Liang Wei", email: "liang.wei@northwind.io", role: "Admin", status: "active", lastActive: "1 hour ago" },
{ id: "m-3", name: "Priya Nair", email: "priya.nair@northwind.io", role: "Editor", status: "invited", lastActive: "Invite pending" },
{ id: "m-4", name: "Marcus Feld", email: "marcus.feld@northwind.io", role: "Editor", status: "suspended", lastActive: "6 days ago" },
{ id: "m-5", name: "Sofia Duarte", email: "sofia.duarte@northwind.io", role: "Viewer", status: "active", lastActive: "Yesterday" },
{ id: "m-6", name: "Tomas Berg", email: "tomas.berg@northwind.io", role: "Billing", status: "active", lastActive: "3 hours ago" },
];
const INVITE_POOL: Array<Pick<Member, "name" | "email" | "role">> = [
{ name: "Yuki Tanaka", email: "yuki.tanaka@northwind.io", role: "Viewer" },
{ name: "Noah Bennett", email: "noah.bennett@northwind.io", role: "Editor" },
{ name: "Ines Costa", email: "ines.costa@northwind.io", role: "Viewer" },
{ name: "Omar Haddad", email: "omar.haddad@northwind.io", role: "Editor" },
];
const AVATAR_TINTS = [
"bg-indigo-500",
"bg-violet-500",
"bg-sky-500",
"bg-emerald-500",
"bg-amber-500",
"bg-rose-500",
];
const STATUS_META: Record<Status, { label: string; badge: string; dot: string }> = {
active: {
label: "Active",
badge:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300",
dot: "bg-emerald-500",
},
invited: {
label: "Invited",
badge:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300",
dot: "bg-amber-500",
},
suspended: {
label: "Suspended",
badge:
"border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-300",
dot: "bg-rose-500",
},
};
const STATUS_RANK: Record<Status, number> = { active: 0, invited: 1, suspended: 2 };
function avatarTint(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return AVATAR_TINTS[hash % AVATAR_TINTS.length];
}
function initials(name: string): string {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
/* ---------- inline icons ---------- */
function KebabIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-5 w-5">
<circle cx="12" cy="5" r="1.7" />
<circle cx="12" cy="12" r="1.7" />
<circle cx="12" cy="19" r="1.7" />
</svg>
);
}
function stroke(children: ReactNode, extra = "h-4 w-4") {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={extra}
>
{children}
</svg>
);
}
const SearchIcon = () => stroke(<><circle cx="11" cy="11" r="7" /><path d="m20 20-3.2-3.2" /></>, "h-4 w-4");
const CopyIcon = () => stroke(<><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h8" /></>);
const BanIcon = () => stroke(<><circle cx="12" cy="12" r="8.5" /><path d="m6 6 12 12" /></>);
const CheckCircleIcon = () => stroke(<><circle cx="12" cy="12" r="8.5" /><path d="m8.5 12 2.5 2.5 4.5-5" /></>);
const DuplicateIcon = () => stroke(<><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h8M13.5 12v4M11.5 14h4" /></>);
const RefreshIcon = () => stroke(<><path d="M20 11a8 8 0 1 0-.7 4.3" /><path d="M20 5v6h-6" /></>);
const TrashIcon = () => stroke(<><path d="M4 7h16M9 7V5a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 5v2M6.5 7l.8 12a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4L18.5 7" /></>);
const PlusIcon = () => stroke(<path d="M12 5v14M5 12h14" />);
const UndoIcon = () => stroke(<><path d="M9 7 4.5 11.5 9 16" /><path d="M4.5 11.5H15a4.5 4.5 0 0 1 0 9h-2" /></>);
const CloseIcon = () => stroke(<path d="m6 6 12 12M18 6 6 18" />);
function SortGlyph({ state }: { state: "asc" | "desc" | "none" }) {
return (
<svg viewBox="0 0 12 16" aria-hidden="true" className="h-3.5 w-3.5 shrink-0">
<path
d="M6 1.5 9 5H3z"
className={state === "asc" ? "fill-indigo-600 dark:fill-indigo-400" : "fill-zinc-300 dark:fill-zinc-600"}
/>
<path
d="M6 14.5 3 11h6z"
className={state === "desc" ? "fill-indigo-600 dark:fill-indigo-400" : "fill-zinc-300 dark:fill-zinc-600"}
/>
</svg>
);
}
/* ---------- row action menu ---------- */
const MENU_WIDTH = 236;
function RowActionMenu({ label, items }: { label: string; items: ActionItem[] }) {
const reduce = useReducedMotion();
const uid = useId();
const menuId = `ta-menu-${uid}`;
const btnId = `ta-btn-${uid}`;
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const [active, setActive] = useState(0);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const place = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const estHeight = items.length * 40 + 16;
let top = r.bottom + 8;
if (top + estHeight > window.innerHeight - 12) {
top = Math.max(12, r.top - estHeight - 8);
}
const left = Math.max(12, Math.min(r.right - MENU_WIDTH, window.innerWidth - MENU_WIDTH - 12));
setCoords({ top, left });
}, [items.length]);
const openMenu = useCallback(
(idx: number) => {
place();
setActive(idx);
setOpen(true);
},
[place],
);
const close = useCallback((focusTrigger: boolean) => {
setOpen(false);
if (focusTrigger) triggerRef.current?.focus();
}, []);
useEffect(() => {
if (open) itemRefs.current[active]?.focus();
}, [open, active]);
useEffect(() => {
if (!open) return;
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (menuRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
setOpen(false);
};
const dismiss = () => setOpen(false);
document.addEventListener("pointerdown", onPointer, true);
window.addEventListener("scroll", dismiss, true);
window.addEventListener("resize", dismiss);
return () => {
document.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("scroll", dismiss, true);
window.removeEventListener("resize", dismiss);
};
}, [open]);
const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openMenu(0);
} else if (e.key === "ArrowUp") {
e.preventDefault();
openMenu(items.length - 1);
}
};
const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActive((i) => (i + 1) % items.length);
break;
case "ArrowUp":
e.preventDefault();
setActive((i) => (i - 1 + items.length) % items.length);
break;
case "Home":
e.preventDefault();
setActive(0);
break;
case "End":
e.preventDefault();
setActive(items.length - 1);
break;
case "Escape":
e.preventDefault();
close(true);
break;
case "Tab":
setOpen(false);
break;
default:
break;
}
};
const runItem = (item: ActionItem) => {
setOpen(false);
triggerRef.current?.focus();
item.onSelect();
};
return (
<>
<button
ref={triggerRef}
id={btnId}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={`Actions for ${label}`}
onClick={() => (open ? close(false) : openMenu(0))}
onKeyDown={onTriggerKeyDown}
className={
"inline-flex h-9 w-9 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-900 " +
(open ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100" : "")
}
>
<KebabIcon />
</button>
<AnimatePresence>
{open && coords ? (
<motion.div
ref={menuRef}
id={menuId}
role="menu"
aria-orientation="vertical"
aria-label={`Actions for ${label}`}
tabIndex={-1}
onKeyDown={onMenuKeyDown}
style={{ position: "fixed", top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: reduce ? 0 : 0.15, ease: [0.16, 1, 0.3, 1] }}
className="z-50 origin-top rounded-xl border border-zinc-200 bg-white p-1.5 shadow-xl shadow-zinc-900/10 ring-1 ring-black/5 dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/50 dark:ring-white/5"
>
{items.map((item, i) => (
<button
key={item.key}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitem"
type="button"
tabIndex={active === i ? 0 : -1}
onClick={() => runItem(item)}
onMouseEnter={() => setActive(i)}
className={
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 " +
(item.destructive
? "text-rose-600 dark:text-rose-400 "
: "text-zinc-700 dark:text-zinc-200 ") +
(active === i
? item.destructive
? "bg-rose-50 dark:bg-rose-500/10 "
: "bg-zinc-100 dark:bg-zinc-800 "
: "")
}
>
<span
className={
"shrink-0 " +
(item.destructive ? "text-rose-500 dark:text-rose-400" : "text-zinc-400 dark:text-zinc-500")
}
>
{item.icon}
</span>
<span className="truncate">{item.label}</span>
</button>
))}
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
/* ---------- status badge ---------- */
function StatusBadge({ status }: { status: Status }) {
const meta = STATUS_META[status];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${meta.badge}`}>
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden="true" />
{meta.label}
</span>
);
}
/* ---------- main component ---------- */
export default function TableActions() {
const reduce = useReducedMotion();
const [members, setMembers] = useState<Member[]>(INITIAL_MEMBERS);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" } | null>(null);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [toast, setToast] = useState<ToastState | null>(null);
const [flashedId, setFlashedId] = useState<string | null>(null);
const idCounter = useRef(7);
const inviteIndex = useRef(0);
const toastTimer = useRef<number | null>(null);
const headerCheckRef = useRef<HTMLInputElement | null>(null);
useEffect(
() => () => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
},
[],
);
const showToast = useCallback((message: string, undo?: () => void) => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
const id = Date.now();
setToast({ id, message, undo });
toastTimer.current = window.setTimeout(() => {
setToast((prev) => (prev && prev.id === id ? null : prev));
}, 6000);
}, []);
const flash = useCallback((id: string) => {
setFlashedId(id);
window.setTimeout(() => setFlashedId((f) => (f === id ? null : f)), 1200);
}, []);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
let rows = members.filter(
(m) =>
!q ||
m.name.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q),
);
if (sort) {
const dir = sort.dir === "asc" ? 1 : -1;
rows = [...rows].sort((a, b) => {
if (sort.key === "status") return (STATUS_RANK[a.status] - STATUS_RANK[b.status]) * dir;
const av = a[sort.key].toLowerCase();
const bv = b[sort.key].toLowerCase();
return av < bv ? -dir : av > bv ? dir : 0;
});
}
return rows;
}, [members, query, sort]);
const allSelected = visible.length > 0 && visible.every((m) => selected.has(m.id));
const someSelected = visible.some((m) => selected.has(m.id));
useEffect(() => {
if (headerCheckRef.current) headerCheckRef.current.indeterminate = someSelected && !allSelected;
}, [someSelected, allSelected]);
const toggleSort = (key: SortKey) =>
setSort((prev) =>
prev && prev.key === key ? (prev.dir === "asc" ? { key, dir: "desc" } : null) : { key, dir: "asc" },
);
const toggleRow = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const toggleAll = () =>
setSelected((prev) => {
const next = new Set(prev);
if (allSelected) visible.forEach((m) => next.delete(m.id));
else visible.forEach((m) => next.add(m.id));
return next;
});
const clearSelection = () => setSelected(new Set());
const removeMembers = (ids: string[], message: string) => {
const idSet = new Set(ids);
const snapshot = members;
setMembers(members.filter((m) => !idSet.has(m.id)));
setSelected((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
showToast(message, () => setMembers(snapshot));
};
const setStatus = (id: string, status: Status) =>
setMembers((prev) => prev.map((m) => (m.id === id ? { ...m, status } : m)));
const copyEmail = (m: Member) => {
try {
void navigator.clipboard?.writeText(m.email);
} catch {
/* clipboard unavailable */
}
showToast(`Copied ${m.email}`);
};
const toggleSuspend = (m: Member) => {
const next: Status = m.status === "suspended" ? "active" : "suspended";
setStatus(m.id, next);
showToast(next === "suspended" ? `${m.name}'s access suspended` : `${m.name} reactivated`);
};
const resetActive = (m: Member) => {
setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, lastActive: "Just now" } : x)));
showToast(`Reset last-active for ${m.name}`);
};
const duplicate = (m: Member) => {
const copyId = `m-${idCounter.current}`;
idCounter.current += 1;
const copy: Member = { ...m, id: copyId, name: `${m.name} (copy)`, status: "invited", lastActive: "Just now" };
setMembers((prev) => {
const idx = prev.findIndex((x) => x.id === m.id);
const next = [...prev];
next.splice(idx + 1, 0, copy);
return next;
});
flash(copyId);
showToast(`Duplicated ${m.name}`);
};
const invitePerson = () => {
const template = INVITE_POOL[inviteIndex.current % INVITE_POOL.length];
inviteIndex.current += 1;
const newId = `m-${idCounter.current}`;
idCounter.current += 1;
const member: Member = { ...template, id: newId, status: "invited", lastActive: "Just now" };
setMembers((prev) => [...prev, member]);
setQuery("");
setSort(null);
flash(newId);
showToast(`Invite sent to ${template.email}`);
};
const bulkSuspend = () => {
const ids = new Set(selected);
setMembers((prev) => prev.map((m) => (ids.has(m.id) ? { ...m, status: "suspended" } : m)));
showToast(`${selected.size} member${selected.size === 1 ? "" : "s"} suspended`);
};
const bulkRemove = () => {
const ids = [...selected];
removeMembers(ids, `${ids.length} member${ids.length === 1 ? "" : "s"} removed`);
};
const rowActions = (m: Member): ActionItem[] => [
{ key: "copy", label: "Copy email address", icon: <CopyIcon />, onSelect: () => copyEmail(m) },
{
key: "status",
label: m.status === "suspended" ? "Reactivate member" : "Suspend access",
icon: m.status === "suspended" ? <CheckCircleIcon /> : <BanIcon />,
onSelect: () => toggleSuspend(m),
},
{ key: "duplicate", label: "Duplicate row", icon: <DuplicateIcon />, onSelect: () => duplicate(m) },
{ key: "reset", label: "Reset last active", icon: <RefreshIcon />, onSelect: () => resetActive(m) },
{
key: "remove",
label: "Remove from workspace",
icon: <TrashIcon />,
destructive: true,
onSelect: () => removeMembers([m.id], `${m.name} removed`),
},
];
const sortableTh = (key: SortKey, labelText: string, extra: string) => {
const isActive = sort?.key === key;
const dir = isActive ? sort!.dir : "none";
return (
<th
scope="col"
aria-sort={isActive ? (dir === "asc" ? "ascending" : "descending") : "none"}
className={`px-4 py-3 ${extra}`}
>
<button
type="button"
onClick={() => toggleSort(key)}
className="group inline-flex items-center gap-1.5 rounded text-xs font-semibold uppercase tracking-wide text-zinc-500 transition-colors hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{labelText}
<SortGlyph state={isActive ? dir : "none"} />
</button>
</th>
);
};
const checkboxClass =
"h-4 w-4 shrink-0 cursor-pointer rounded border-zinc-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-600 dark:focus-visible:ring-offset-zinc-900";
return (
<section className="relative w-full bg-zinc-50 px-4 py-16 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 sm:px-6 lg:py-24">
<style>{`
@keyframes ta_rowFlash {
0% { background-color: rgba(99,102,241,0.20); }
100% { background-color: rgba(99,102,241,0); }
}
.ta-row-flash { animation: ta_rowFlash 1.2s ease-out; }
@media (prefers-reduced-motion: reduce) {
.ta-row-flash { animation: none; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<div className="mb-8 flex flex-col gap-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Northwind workspace
</p>
<h2 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">
Workspace members
</h2>
<p className="max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
Manage who can access the workspace. Use the actions menu on any row to copy details, change access, or
remove a member.
</p>
</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
{/* toolbar */}
<div className="flex flex-col gap-3 border-b border-zinc-200 p-4 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between sm:p-5">
<div className="relative w-full sm:max-w-xs">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-zinc-500">
<SearchIcon />
</span>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name, email or role"
aria-label="Search members"
className="w-full rounded-lg border border-zinc-300 bg-zinc-50 py-2 pl-9 pr-3 text-sm text-zinc-900 placeholder-zinc-400 transition-colors focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<button
type="button"
onClick={invitePerson}
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
>
<PlusIcon />
Invite people
</button>
</div>
{/* bulk action bar */}
<AnimatePresence initial={false}>
{selected.size > 0 ? (
<motion.div
key="bulk"
role="region"
aria-label="Bulk actions"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduce ? 0 : 0.18 }}
className="flex flex-wrap items-center gap-3 border-b border-indigo-200 bg-indigo-50 px-4 py-3 text-sm dark:border-indigo-900/50 dark:bg-indigo-950/40 sm:px-5"
>
<span className="font-semibold text-indigo-700 dark:text-indigo-300" aria-live="polite">
{selected.size} selected
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<button
type="button"
onClick={bulkSuspend}
className="inline-flex items-center gap-1.5 rounded-md border border-indigo-200 bg-white px-2.5 py-1.5 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900/60"
>
<BanIcon />
Suspend
</button>
<button
type="button"
onClick={bulkRemove}
className="inline-flex items-center gap-1.5 rounded-md border border-rose-200 bg-white px-2.5 py-1.5 text-xs font-medium text-rose-600 transition-colors hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:border-rose-900/60 dark:bg-transparent dark:text-rose-400 dark:hover:bg-rose-950/40"
>
<TrashIcon />
Remove
</button>
<button
type="button"
onClick={clearSelection}
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-zinc-500 transition-colors hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Clear
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
{/* table */}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<caption className="sr-only">
Workspace members. Use the actions menu button on each row to manage a member.
</caption>
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-800">
<th scope="col" className="w-12 px-4 py-3">
<input
ref={headerCheckRef}
type="checkbox"
checked={allSelected}
onChange={toggleAll}
aria-label="Select all visible members"
className={checkboxClass}
/>
</th>
{sortableTh("name", "Member", "")}
{sortableTh("role", "Role", "hidden md:table-cell")}
{sortableTh("status", "Status", "")}
<th
scope="col"
className="hidden px-4 py-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 sm:table-cell"
>
Last active
</th>
<th scope="col" className="w-16 px-4 py-3 text-right">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{visible.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-16 text-center">
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-200">No members found</p>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Nothing matches “{query}”.
</p>
<button
type="button"
onClick={() => setQuery("")}
className="mt-4 rounded-lg border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Clear search
</button>
</td>
</tr>
) : (
visible.map((m) => {
const isSelected = selected.has(m.id);
return (
<tr
key={m.id}
className={
(flashedId === m.id ? "ta-row-flash " : "") +
"transition-colors " +
(isSelected
? "bg-indigo-50/70 dark:bg-indigo-500/10"
: "hover:bg-zinc-50 dark:hover:bg-zinc-800/40")
}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(m.id)}
aria-label={`Select ${m.name}`}
className={checkboxClass}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white ${avatarTint(m.id)}`}
aria-hidden="true"
>
{initials(m.name)}
</span>
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900 dark:text-zinc-100">{m.name}</div>
<div className="truncate text-xs text-zinc-500 dark:text-zinc-400">{m.email}</div>
</div>
</div>
</td>
<td className="hidden px-4 py-3 text-zinc-600 dark:text-zinc-300 md:table-cell">{m.role}</td>
<td className="px-4 py-3">
<StatusBadge status={m.status} />
</td>
<td className="hidden px-4 py-3 text-zinc-500 dark:text-zinc-400 sm:table-cell">
{m.lastActive}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end">
<RowActionMenu label={m.name} items={rowActions(m)} />
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* footer */}
<div className="flex items-center justify-between border-t border-zinc-200 px-4 py-3 text-xs text-zinc-500 dark:border-zinc-800 dark:text-zinc-400 sm:px-5">
<span>
Showing {visible.length} of {members.length} members
</span>
<span>{selected.size} selected</span>
</div>
</div>
</div>
{/* toast */}
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[60] flex justify-center px-4">
<AnimatePresence>
{toast ? (
<motion.div
key={toast.id}
role="status"
aria-live="polite"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: reduce ? 0 : 0.2, ease: [0.16, 1, 0.3, 1] }}
className="pointer-events-auto flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 py-2.5 pl-4 pr-2.5 text-sm text-zinc-100 shadow-lg shadow-black/30 dark:border-zinc-700 dark:bg-zinc-800"
>
<span className="font-medium">{toast.message}</span>
{toast.undo ? (
<button
type="button"
onClick={() => {
toast.undo?.();
if (toastTimer.current) window.clearTimeout(toastTimer.current);
setToast(null);
}}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-700 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-zinc-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
>
<UndoIcon />
Undo
</button>
) : null}
<button
type="button"
onClick={() => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
setToast(null);
}}
aria-label="Dismiss notification"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<CloseIcon />
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Sortable Table
Originalsortable-column table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Pagination Table
Originaltable with footer pagination

Search Table
Originaltable with a search filter

Expandable Table
Originaltable with expandable rows

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

