Web InnoventixFreeCode

Actions Table

Original · free

table with row action menus

byWeb InnoventixReact + Tailwind
tableactionstables
Open

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.json
table-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 &ldquo;{query}&rdquo;.
                      </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 quote

Similar components

Browse all →