User Admin Table

Team members with initial avatars, inline role menu, status pills, filter chips and a hover actions menu.

  • Tables
  • SaaS
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Menus use position: fixed and close on scroll.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/user-admin-table.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/tables/user-admin-table.tsx
1/**
2 * UserAdminTable — a team-admin table: initial avatars with a presence dot,
3 * an inline role dropdown, status pills, search plus filter chips, and a row
4 * actions menu that appears on hover or focus.
5 *
6 * Props: users, roles, onRoleChange(id, role), onAction(id, action),
7 * theme. Actions: "edit" | "resend" | "suspend" | "reactivate" | "remove".
8 * Controlled data: pass `users` and update it from the callbacks.
9 *
10 * Browser support: all evergreen browsers. Popovers use position: fixed so they
11 * escape the scroll container; they close on scroll and resize.
12 * Accessibility: native table semantics; the role trigger is a menu button
13 * (aria-haspopup="menu", aria-expanded) opening a menuitemradio group; the
14 * actions trigger is a labelled menu button. Arrow keys / Home / End move,
15 * Enter or Space selects, Escape closes and returns focus, Tab closes.
16 * Filter chips are toggle buttons (aria-pressed) with counts; results are
17 * announced in a polite live region. Row actions stay visible on touch and
18 * whenever focus is inside the row.
19 * Reduced motion: popovers fade without movement.
20 */
21import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
22
23export type UserStatus = "active" | "invited" | "suspended";
24export interface AdminUser {
25 id: string;
26 name: string;
27 email: string;
28 role: string;
29 status: UserStatus;
30 lastActive: string;
31}
32export type UserAction = "edit" | "resend" | "suspend" | "reactivate" | "remove";
33
34export interface UserAdminTableProps {
35 users: AdminUser[];
36 roles?: { id: string; label: string; hint: string }[];
37 onRoleChange?: (id: string, role: string) => void;
38 onAction?: (id: string, action: UserAction) => void;
39 theme?: "light" | "dark";
40 className?: string;
41}
42
43const DEFAULT_ROLES = [
44 { id: "owner", label: "Owner", hint: "Full access, billing and deletion" },
45 { id: "admin", label: "Admin", hint: "Manage members and settings" },
46 { id: "member", label: "Member", hint: "Create and edit projects" },
47 { id: "viewer", label: "Viewer", hint: "Read-only access" },
48];
49
50const hue = (s: string) => {
51 let h = 0;
52 for (const c of s) h = (h * 31 + c.charCodeAt(0)) % 360;
53 return h;
54};
55const initials = (n: string) => n.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase();
56const STATUS = { active: "Active", invited: "Invited", suspended: "Suspended" } as const;
57
58type Pop = { id: string; kind: "role" | "actions"; x: number; y: number; w: number; up: boolean } | null;
59
60/** Tracks whether the table's own container is narrow, so hidden columns are not rendered at all (keeps colSpan rows correct). */
61function useNarrow(max: number) {
62 const ref = useRef<HTMLDivElement>(null);
63 const [narrow, setNarrow] = useState(false);
64 useEffect(() => {
65 const el = ref.current;
66 if (!el || typeof ResizeObserver === "undefined") return;
67 const f = () => setNarrow(el.clientWidth <= max);
68 f();
69 const ro = new ResizeObserver(f);
70 ro.observe(el);
71 return () => ro.disconnect();
72 }, [max]);
73 return [ref, narrow] as const;
74}
75export function UserAdminTable({ users, roles = DEFAULT_ROLES, onRoleChange, onAction, theme, className }: UserAdminTableProps) {
76 const uid = useId();
77 const [cardRef, narrow] = useNarrow(640);
78 const [q, setQ] = useState("");
79 const [filter, setFilter] = useState<"all" | UserStatus | "admins">("all");
80 const [pop, setPop] = useState<Pop>(null);
81 const trigger = useRef<HTMLElement | null>(null);
82 const menu = useRef<HTMLDivElement>(null);
83
84 const counts = useMemo(
85 () => ({
86 all: users.length,
87 admins: users.filter((u) => u.role === "owner" || u.role === "admin").length,
88 active: users.filter((u) => u.status === "active").length,
89 invited: users.filter((u) => u.status === "invited").length,
90 suspended: users.filter((u) => u.status === "suspended").length,
91 }),
92 [users],
93 );
94
95 const rows = useMemo(() => {
96 const t = q.trim().toLowerCase();
97 return users.filter((u) => {
98 if (filter === "admins" ? !(u.role === "owner" || u.role === "admin") : filter !== "all" && u.status !== filter) return false;
99 return !t || (u.name + " " + u.email).toLowerCase().includes(t);
100 });
101 }, [users, q, filter]);
102
103 const close = useCallback((restore = true) => {
104 setPop(null);
105 if (restore) requestAnimationFrame(() => trigger.current?.focus());
106 }, []);
107
108 const open = (e: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, id: string, kind: "role" | "actions", h: number) => {
109 const el = e.currentTarget;
110 trigger.current = el;
111 if (pop && pop.id === id && pop.kind === kind) return close();
112 const r = el.getBoundingClientRect();
113 const w = kind === "role" ? 264 : 232;
114 const up = window.innerHeight - r.bottom < h + 16 && r.top > h + 16;
115 const x = Math.max(8, Math.min(kind === "role" ? r.left : r.right - w, window.innerWidth - w - 8));
116 setPop({ id, kind, x, y: up ? r.top - 6 : r.bottom + 6, w, up });
117 };
118
119 useEffect(() => {
120 if (!pop) return;
121 const items = () => [...(menu.current?.querySelectorAll<HTMLElement>("[role^=menuitem]") ?? [])];
122 const cur = items().find((i) => i.getAttribute("aria-checked") === "true") ?? items()[0];
123 cur?.focus();
124 const dismiss = (e: Event) => {
125 if (!menu.current?.contains(e.target as Node) && !trigger.current?.contains(e.target as Node)) setPop(null);
126 };
127 const away = () => setPop(null);
128 document.addEventListener("pointerdown", dismiss);
129 window.addEventListener("resize", away);
130 window.addEventListener("scroll", away, true);
131 return () => {
132 document.removeEventListener("pointerdown", dismiss);
133 window.removeEventListener("resize", away);
134 window.removeEventListener("scroll", away, true);
135 };
136 }, [pop?.id, pop?.kind]); // eslint-disable-line react-hooks/exhaustive-deps
137
138 useLayoutEffect(() => {
139 // the menu measures after mount: if it would overflow the bottom, nudge up
140 const m = menu.current;
141 if (!m || !pop || pop.up) return;
142 const r = m.getBoundingClientRect();
143 if (r.bottom > window.innerHeight - 8) m.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
144 }, [pop]);
145
146 const onMenuKey = (e: React.KeyboardEvent) => {
147 const list = [...(menu.current?.querySelectorAll<HTMLElement>("[role^=menuitem]:not([disabled])") ?? [])];
148 const i = list.indexOf(document.activeElement as HTMLElement);
149 const go = (n: number) => (list[(n + list.length) % list.length] as HTMLElement).focus();
150 if (e.key === "ArrowDown") { e.preventDefault(); go(i + 1); }
151 else if (e.key === "ArrowUp") { e.preventDefault(); go(i - 1); }
152 else if (e.key === "Home") { e.preventDefault(); go(0); }
153 else if (e.key === "End") { e.preventDefault(); go(list.length - 1); }
154 else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); }
155 else if (e.key === "Tab") close(false);
156 };
157
158 const target = pop ? users.find((u) => u.id === pop.id) : undefined;
159 const run = (a: UserAction) => {
160 if (!target) return;
161 onAction?.(target.id, a);
162 close(a !== "remove");
163 };
164
165 const chips: { id: typeof filter; label: string }[] = [
166 { id: "all", label: "All" },
167 { id: "admins", label: "Admins" },
168 { id: "active", label: "Active" },
169 { id: "invited", label: "Invited" },
170 { id: "suspended", label: "Suspended" },
171 ];
172
173 return (
174 <div className={`fd-ua${className ? " " + className : ""}`} data-fd-theme={theme}>
175 <style>{css}</style>
176 <div className="fd-ua-tools">
177 <label className="fd-ua-search">
178 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.5" /><path d="M10.5 10.5L14 14" /></svg>
179 <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name or email" aria-label="Search members" />
180 </label>
181 <div className="fd-ua-chips" role="group" aria-label="Filter members">
182 {chips.map((c) => (
183 <button key={c.id} type="button" className="fd-ua-chip" aria-pressed={filter === c.id} onClick={() => setFilter(c.id)}>
184 {c.label}<span>{counts[c.id]}</span>
185 </button>
186 ))}
187 </div>
188 </div>
189
190 <div className="fd-ua-card" ref={cardRef}>
191 <div className="fd-ua-scroll" role="region" aria-label="Team members, scrollable" tabIndex={0}>
192 <table className="fd-ua-table">
193 <caption className="fd-ua-sr">Team members</caption>
194 <thead>
195 <tr>
196 <th scope="col" className="fd-ua-th">Member</th>
197 <th scope="col" className="fd-ua-th" style={{ width: 176 }}>Role</th>
198 {!narrow && <th scope="col" className="fd-ua-th" style={{ width: 128 }}>Status</th>}
199 {!narrow && <th scope="col" className="fd-ua-th" style={{ width: 128 }}>Last active</th>}
200 <th scope="col" className="fd-ua-th" style={{ width: 56 }}><span className="fd-ua-sr">Actions</span></th>
201 </tr>
202 </thead>
203 <tbody>
204 {rows.map((u) => {
205 const rl = roles.find((r) => r.id === u.role);
206 const isOpen = pop?.id === u.id;
207 return (
208 <tr key={u.id} className="fd-ua-row" data-open={isOpen ? "" : undefined} data-s={u.status}>
209 <td className="fd-ua-td">
210 <div className="fd-ua-who">
211 <span className="fd-ua-av" style={{ ["--h" as string]: hue(u.name) }} aria-hidden="true">
212 {initials(u.name)}
213 <i className="fd-ua-dot" data-s={u.status} />
214 </span>
215 <span className="fd-ua-nm"><b>{u.name}</b><small>{u.email}</small></span>
216 </div>
217 </td>
218 <td className="fd-ua-td">
219 <button
220 type="button"
221 className="fd-ua-role"
222 aria-haspopup="menu"
223 aria-expanded={isOpen && pop?.kind === "role"}
224 aria-label={`Role for ${u.name}: ${rl?.label}. Change role`}
225 disabled={u.role === "owner"}
226 onClick={(e) => open(e, u.id, "role", roles.length * 52 + 12)}
227 onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); open(e, u.id, "role", roles.length * 52 + 12); } }}
228 >
229 <span>{rl?.label ?? u.role}</span>
230 {u.role !== "owner" && <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 4.5l3 3 3-3" /></svg>}
231 </button>
232 </td>
233 {!narrow && <td className="fd-ua-td"><span className="fd-ua-pill" data-s={u.status}><i />{STATUS[u.status]}</span></td>}
234 {!narrow && <td className="fd-ua-td fd-ua-mut">{u.lastActive}</td>}
235 <td className="fd-ua-td fd-ua-end">
236 <button
237 type="button"
238 className="fd-ua-more"
239 aria-haspopup="menu"
240 aria-expanded={isOpen && pop?.kind === "actions"}
241 aria-label={`Actions for ${u.name}`}
242 onClick={(e) => open(e, u.id, "actions", 5 * 40 + 16)}
243 onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); open(e, u.id, "actions", 5 * 40 + 16); } }}
244 >
245 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.3" /><circle cx="8" cy="8" r="1.3" /><circle cx="12.5" cy="8" r="1.3" /></svg>
246 </button>
247 </td>
248 </tr>
249 );
250 })}
251 {rows.length === 0 && (
252 <tr>
253 <td className="fd-ua-empty" colSpan={narrow ? 3 : 5}>
254 <svg width="36" height="36" viewBox="0 0 36 36" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="14" cy="13" r="5" /><path d="M5 29c1-5 5-8 9-8s8 3 9 8M24 10c3 0 5 2 5 5M26 21c3 .8 5 3 6 6" /></svg>
255 <b>No members found</b>
256 <span>{q ? `Nobody matches “${q}”.` : "No one has this status yet."}</span>
257 <button type="button" className="fd-ua-reset" onClick={() => { setQ(""); setFilter("all"); }}>Reset filters</button>
258 </td>
259 </tr>
260 )}
261 </tbody>
262 </table>
263 </div>
264 <div className="fd-ua-foot"><span>Showing <b>{rows.length}</b> of {users.length} members</span></div>
265 </div>
266 <div className="fd-ua-sr" role="status" aria-live="polite">{`${rows.length} ${rows.length === 1 ? "member" : "members"} shown`}</div>
267
268 {pop && target && (
269 <div
270 ref={menu}
271 id={`${uid}-menu`}
272 role="menu"
273 aria-label={pop.kind === "role" ? `Role for ${target.name}` : `Actions for ${target.name}`}
274 className="fd-ua-menu"
275 data-fd-theme={theme}
276 data-up={pop.up ? "" : undefined}
277 style={{ left: pop.x, top: pop.up ? undefined : pop.y, bottom: pop.up ? window.innerHeight - pop.y : undefined, width: pop.w }}
278 onKeyDown={onMenuKey}
279 >
280 {pop.kind === "role" ? (
281 roles.map((r) => (
282 <button
283 key={r.id}
284 type="button"
285 role="menuitemradio"
286 aria-checked={r.id === target.role}
287 className="fd-ua-mi fd-ua-mi-role"
288 disabled={r.id === "owner"}
289 onClick={() => { onRoleChange?.(target.id, r.id); close(); }}
290 >
291 <span className="fd-ua-mt"><b>{r.label}</b><small>{r.hint}</small></span>
292 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" /></svg>
293 </button>
294 ))
295 ) : (
296 <>
297 <button type="button" role="menuitem" className="fd-ua-mi" onClick={() => run("edit")}>
298 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 13.5l.7-3L10.8 3a1.4 1.4 0 012 2l-7.6 7.6z" /></svg>Edit profile
299 </button>
300 {target.status === "invited" && (
301 <button type="button" role="menuitem" className="fd-ua-mi" onClick={() => run("resend")}>
302 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3.5" width="12" height="9" rx="2" /><path d="M2.5 5l5.5 4 5.5-4" /></svg>Resend invite
303 </button>
304 )}
305 {target.status !== "invited" && target.role !== "owner" && (
306 <button type="button" role="menuitem" className="fd-ua-mi" onClick={() => run(target.status === "suspended" ? "reactivate" : "suspend")}>
307 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="5.5" /><path d={target.status === "suspended" ? "M6.5 5.5v5l4-2.5z" : "M4.2 11.8l7.6-7.6"} /></svg>
308 {target.status === "suspended" ? "Reactivate" : "Suspend access"}
309 </button>
310 )}
311 {target.role !== "owner" && (
312 <>
313 <div className="fd-ua-sep" role="separator" />
314 <button type="button" role="menuitem" className="fd-ua-mi" data-danger="" onClick={() => run("remove")}>
315 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 4.5h11M6 4.5V3a1 1 0 011-1h2a1 1 0 011 1v1.5M4 4.5l.6 8a1 1 0 001 .9h4.8a1 1 0 001-.9l.6-8" /></svg>Remove from workspace
316 </button>
317 </>
318 )}
319 </>
320 )}
321 </div>
322 )}
323 </div>
324 );
325}
326
327const css = `
328:where(.fd-ua,.fd-ua *,.fd-ua-menu,.fd-ua-menu *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}
329:where(.fd-ua,.fd-ua *,.fd-ua-menu,.fd-ua-menu *)::before,:where(.fd-ua,.fd-ua *,.fd-ua-menu,.fd-ua-menu *)::after{box-sizing:border-box}
330:where(.fd-ua svg,.fd-ua-menu svg){display:block;flex-shrink:0}
331:where(.fd-ua button,.fd-ua input,.fd-ua-menu button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer}
332:where(.fd-ua table){border-collapse:separate;border-spacing:0}
333.fd-ua,.fd-ua-menu{
334 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
335 --u-bg:#fff;--u-head:#FAFAF9;--u-fg:#16151A;--u-mut:#6E6B78;--u-line:rgba(22,21,26,.08);--u-line2:rgba(22,21,26,.16);--u-hover:rgba(22,21,26,.03);--u-accent:#0F766E;--u-ring:rgba(15,118,110,.5);--u-pop:#fff;
336 font:400 14px/20px ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.006em;color:var(--u-fg);-webkit-font-smoothing:antialiased}
337[data-fd-theme="dark"].fd-ua,[data-fd-theme="dark"] .fd-ua,.fd-ua-menu[data-fd-theme="dark"]{--u-bg:#131217;--u-head:#17161C;--u-fg:#F3F2F6;--u-mut:#9F9CAB;--u-line:rgba(255,255,255,.07);--u-line2:rgba(255,255,255,.16);--u-hover:rgba(255,255,255,.035);--u-accent:#2DD4BF;--u-ring:rgba(45,212,191,.55);--u-pop:#1D1C23}
338.fd-ua{width:100%}
339.fd-ua-tools{display:flex;align-items:center;justify-content:space-between;gap:12px 16px;flex-wrap:wrap;margin-bottom:12px}
340.fd-ua-search{position:relative;display:block;flex:1 1 240px;max-width:320px}
341.fd-ua-search svg{position:absolute;left:12px;top:10px;color:var(--u-mut);pointer-events:none}
342.fd-ua-search input{width:100%;height:36px;padding:0 12px 0 36px;border-radius:10px;background:var(--u-bg);box-shadow:0 0 0 1px var(--u-line2),0 1px 2px rgba(22,21,26,.04);font-size:14px;color:var(--u-fg);cursor:text;transition:box-shadow var(--fd-dur-1)}
343.fd-ua-search input::placeholder{color:var(--u-mut)}
344.fd-ua-search input:focus-visible{box-shadow:0 0 0 1.5px var(--u-accent),0 0 0 4px var(--u-ring)}
345.fd-ua-chips{display:flex;gap:6px;flex-wrap:wrap}
346.fd-ua-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:9999px;font-size:13px;font-weight:500;line-height:1;color:var(--u-mut);box-shadow:inset 0 0 0 1px var(--u-line2);transition:background-color var(--fd-dur-1),color var(--fd-dur-1),box-shadow var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}
347.fd-ua-chip span{font-size:12px;font-variant-numeric:tabular-nums;opacity:.65}
348@media (hover:hover){.fd-ua-chip:hover:not([aria-pressed=true]){color:var(--u-fg);background:var(--u-hover)}}
349.fd-ua-chip:active{transform:scale(.96)}
350.fd-ua-chip[aria-pressed=true]{background:var(--u-fg);color:var(--u-bg);box-shadow:none}
351.fd-ua-chip:focus-visible,.fd-ua-role:focus-visible,.fd-ua-more:focus-visible,.fd-ua-reset:focus-visible{box-shadow:0 0 0 2px var(--u-bg),0 0 0 4px var(--u-ring)}
352.fd-ua-card{border-radius:16px;background:var(--u-bg);overflow:hidden;box-shadow:0 0 0 1px var(--u-line),0 1px 2px rgba(22,21,26,.04),0 12px 32px -16px rgba(22,21,26,.12);container-type:inline-size}
353.fd-ua-scroll{overflow-x:auto}.fd-ua-scroll:focus-visible{outline:2px solid var(--u-ring);outline-offset:-2px}
354.fd-ua-table{width:100%;table-layout:fixed;min-width:520px}
355.fd-ua-th{height:40px;padding:0 16px;background:var(--u-head);font-size:12px;line-height:16px;font-weight:500;color:var(--u-mut);text-align:left;box-shadow:inset 0 -1px 0 var(--u-line);white-space:nowrap}
356.fd-ua-td{height:64px;padding:0 16px;border-bottom:1px solid var(--u-line);vertical-align:middle;white-space:nowrap;overflow:visible;transition:background-color var(--fd-dur-1)}
357.fd-ua-row:last-child .fd-ua-td{border-bottom-color:transparent}
358@media (hover:hover){.fd-ua-row:hover .fd-ua-td{background:var(--u-hover)}}
359.fd-ua-row[data-open] .fd-ua-td{background:var(--u-hover)}
360.fd-ua-row[data-s="suspended"] .fd-ua-nm,.fd-ua-row[data-s="suspended"] .fd-ua-av{opacity:.55}
361.fd-ua-mut{color:var(--u-mut);font-variant-numeric:tabular-nums}
362.fd-ua-who{display:flex;align-items:center;gap:12px;min-width:0}
363.fd-ua-av{position:relative;display:grid;place-items:center;flex:none;width:36px;height:36px;border-radius:50%;font-size:13px;font-weight:600;letter-spacing:0;color:hsl(var(--h) 50% 28%);background:hsl(var(--h) 70% 90%);box-shadow:inset 0 0 0 1px hsl(var(--h) 50% 40% / .12)}
364[data-fd-theme="dark"] .fd-ua-av,[data-fd-theme="dark"].fd-ua .fd-ua-av{color:hsl(var(--h) 80% 85%);background:hsl(var(--h) 35% 24%)}
365.fd-ua-dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;background:#16A34A;box-shadow:0 0 0 2px var(--u-bg)}
366.fd-ua-dot[data-s="invited"]{background:#D97706}.fd-ua-dot[data-s="suspended"]{background:#9CA3AF}
367.fd-ua-nm{display:flex;flex-direction:column;min-width:0}
368.fd-ua-nm b{font-weight:500;overflow:hidden;text-overflow:ellipsis}
369.fd-ua-nm small{font-size:13px;line-height:18px;color:var(--u-mut);overflow:hidden;text-overflow:ellipsis}
370.fd-ua-role{display:inline-flex;align-items:center;gap:6px;height:32px;margin-left:-10px;padding:0 10px;border-radius:9px;font-size:14px;line-height:1;font-weight:500;transition:background-color var(--fd-dur-1),box-shadow var(--fd-dur-1)}
371.fd-ua-role svg{color:var(--u-mut);transition:transform var(--fd-dur-2) var(--fd-ease-out)}
372.fd-ua-role[aria-expanded=true] svg{transform:rotate(180deg)}
373@media (hover:hover){.fd-ua-role:hover:not(:disabled){box-shadow:inset 0 0 0 1px var(--u-line2);background:var(--u-bg)}}
374.fd-ua-role[aria-expanded=true]{box-shadow:inset 0 0 0 1px var(--u-line2);background:var(--u-bg)}
375.fd-ua-role:disabled{cursor:default;color:var(--u-mut)}
376.fd-ua-pill{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px 0 8px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1;--c:#15803D;color:var(--c);background:color-mix(in srgb,var(--c) 11%,transparent)}
377.fd-ua-pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
378.fd-ua-pill[data-s="invited"]{--c:#B45309}.fd-ua-pill[data-s="suspended"]{--c:#6B7280}
379[data-fd-theme="dark"] .fd-ua-pill{--c:#4ADE80}[data-fd-theme="dark"] .fd-ua-pill[data-s="invited"]{--c:#FBBF24}[data-fd-theme="dark"] .fd-ua-pill[data-s="suspended"]{--c:#A1A1AA}
380.fd-ua-end{text-align:right}
381.fd-ua-more{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:9px;color:var(--u-mut);opacity:0;transform:scale(.92);transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-1),color var(--fd-dur-1)}
382.fd-ua-row:hover .fd-ua-more,.fd-ua-row:focus-within .fd-ua-more,.fd-ua-more[aria-expanded=true]{opacity:1;transform:none}
383.fd-ua-more:hover{background:var(--u-line);color:var(--u-fg)}
384.fd-ua-more[aria-expanded=true]{background:var(--u-line);color:var(--u-fg)}
385@media (hover:none){.fd-ua-more{opacity:1;transform:none}}
386.fd-ua-empty{height:280px;text-align:center;color:var(--u-mut);padding:0}
387.fd-ua-empty svg{margin:0 auto 12px;opacity:.7}
388.fd-ua-empty b{display:block;font-size:15px;line-height:20px;font-weight:600;color:var(--u-fg)}
389.fd-ua-empty span{display:block;margin-top:4px;font-size:13px}
390.fd-ua-reset{margin-top:16px;height:32px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:500;line-height:1;color:var(--u-fg);box-shadow:inset 0 0 0 1px var(--u-line2)}
391.fd-ua-foot{display:flex;align-items:center;min-height:48px;padding:0 16px;border-top:1px solid var(--u-line);font-size:13px;color:var(--u-mut);font-variant-numeric:tabular-nums}.fd-ua-foot b{font-weight:500;color:var(--u-fg)}
392.fd-ua-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
393.fd-ua-menu{position:fixed;z-index:1000;padding:6px;border-radius:14px;background:var(--u-pop);box-shadow:0 0 0 1px var(--u-line2),0 2px 4px rgba(22,21,26,.06),0 16px 40px -8px rgba(22,21,26,.28);animation:fd-ua-in var(--fd-dur-2) var(--fd-ease-out);transform-origin:top left}
394.fd-ua-menu[data-up]{transform-origin:bottom left}
395.fd-ua-mi{white-space:nowrap;display:flex;align-items:center;gap:10px;width:100%;min-height:36px;padding:0 10px;border-radius:9px;text-align:left;font-size:14px;line-height:20px;font-weight:450;color:var(--u-fg);transition:background-color 80ms}
396.fd-ua-mi svg{color:var(--u-mut)}
397.fd-ua-mi:hover:not(:disabled),.fd-ua-mi:focus{background:var(--u-hover);background:color-mix(in srgb,var(--u-fg) 6%,transparent);outline:0}
398.fd-ua-mi:disabled{opacity:.45;cursor:default}
399.fd-ua-mi[data-danger],.fd-ua-mi[data-danger] svg{color:#C2271B}
400[data-fd-theme="dark"].fd-ua-menu .fd-ua-mi[data-danger],[data-fd-theme="dark"].fd-ua-menu .fd-ua-mi[data-danger] svg{color:#FF8A7A}
401.fd-ua-mi-role{min-height:52px;padding:6px 10px}
402.fd-ua-mt{display:flex;flex-direction:column;flex:1;min-width:0}.fd-ua-mt b{font-weight:500}.fd-ua-mt small{font-size:12px;line-height:16px;color:var(--u-mut)}
403.fd-ua-mi-role>svg{color:var(--u-accent);opacity:0;transform:scale(.6);transition:opacity var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}
404.fd-ua-mi-role[aria-checked=true]>svg{opacity:1;transform:none}
405.fd-ua-sep{height:1px;margin:6px -6px;background:var(--u-line)}
406@keyframes fd-ua-in{from{opacity:0;transform:translateY(-4px) scale(.97)}}
407.fd-ua-menu[data-up]{animation-name:fd-ua-in-up}@keyframes fd-ua-in-up{from{opacity:0;transform:translateY(4px) scale(.97)}}
408@container (max-width:640px){[data-nh]{display:none}.fd-ua-table{min-width:0}.fd-ua-th,.fd-ua-td{padding:0 12px}.fd-ua-th:nth-child(2){width:116px!important}.fd-ua-th:last-child{width:48px!important}.fd-ua-more{margin-right:-4px}}
409@media (prefers-reduced-motion:reduce){.fd-ua *,.fd-ua-menu{transition-duration:1ms!important;animation:none!important}.fd-ua-more{transform:none}}
410@media (prefers-contrast:more){.fd-ua-card{box-shadow:0 0 0 1px currentColor}}
411`;
412
413/* ───────────────────────────── demo ───────────────────────────── */
414
415const SEED: AdminUser[] = [
416 { id: "u1", name: "Priya Raman", email: "priya@parcelhq.com", role: "owner", status: "active", lastActive: "Just now" },
417 { id: "u2", name: "Mateo Alvarez", email: "mateo@parcelhq.com", role: "admin", status: "active", lastActive: "12 min ago" },
418 { id: "u3", name: "Hannah Okafor", email: "hannah.okafor@parcelhq.com", role: "member", status: "active", lastActive: "2 hours ago" },
419 { id: "u4", name: "Jonas Lindqvist", email: "jonas@lindqvist.studio", role: "member", status: "invited", lastActive: "Never" },
420 { id: "u5", name: "Aiko Tanabe", email: "aiko@parcelhq.com", role: "admin", status: "active", lastActive: "Yesterday" },
421 { id: "u6", name: "Samira Haddad", email: "samira.haddad@parcelhq.com", role: "member", status: "active", lastActive: "Yesterday" },
422 { id: "u7", name: "Devon Whitaker", email: "devon@whitakerlogistics.co", role: "viewer", status: "active", lastActive: "3 days ago" },
423 { id: "u8", name: "Lucía Fernández", email: "lucia@parcelhq.com", role: "member", status: "suspended", lastActive: "Sep 14" },
424 { id: "u9", name: "Tobias Reuter", email: "t.reuter@parcelhq.com", role: "viewer", status: "invited", lastActive: "Never" },
425 { id: "u10", name: "Naledi Dlamini", email: "naledi@parcelhq.com", role: "member", status: "active", lastActive: "5 days ago" },
426];
427
428export function Demo() {
429 const [theme, setTheme] = useState<"light" | "dark">("light");
430 const [users, setUsers] = useState(SEED);
431 const [note, setNote] = useState<{ text: string; undo?: () => void } | null>(null);
432 const timer = useRef<number>(0);
433 const say = (text: string, undo?: () => void) => {
434 setNote({ text, undo });
435 window.clearTimeout(timer.current);
436 timer.current = window.setTimeout(() => setNote(null), 6000);
437 };
438 const seats = users.filter((u) => u.status !== "suspended").length;
439
440 return (
441 <div className="fd-uad" data-fd-theme={theme}>
442 <style>{demoCss}</style>
443 <aside className="fd-uad-side" aria-label="Workspace">
444 <div className="fd-uad-ws"><span className="fd-uad-mark" aria-hidden="true">P</span><span><b>Parcel</b><small>Workspace</small></span></div>
445 <nav aria-label="Settings">
446 <a href="#general">General</a>
447 <a href="#members" aria-current="page">Members</a>
448 <a href="#billing">Billing</a>
449 <a href="#integrations">Integrations</a>
450 <a href="#security">Security</a>
451 </nav>
452 <button type="button" className="fd-uad-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"} theme</button>
453 </aside>
454 <main className="fd-uad-main">
455 <header className="fd-uad-head">
456 <div><h1>Members</h1><p>Control who can access Parcel and what they can do.</p></div>
457 <button type="button" className="fd-uad-invite" onClick={() => say("Invite link copied to your clipboard.")}>
458 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>Invite member
459 </button>
460 </header>
461 <div className="fd-uad-seats" role="group" aria-label="Seat usage">
462 <div><b>{seats}</b> of 12 seats in use<small>Suspended members don’t use a seat.</small></div>
463 <div className="fd-uad-meter" role="meter" aria-valuemin={0} aria-valuemax={12} aria-valuenow={seats} aria-label="Seats used"><i style={{ width: `${(seats / 12) * 100}%` }} /></div>
464 </div>
465 <UserAdminTable
466 theme={theme}
467 users={users}
468 onRoleChange={(id, role) => {
469 const u = users.find((x) => x.id === id)!;
470 setUsers((p) => p.map((x) => (x.id === id ? { ...x, role } : x)));
471 say(`${u.name} is now ${role === "admin" ? "an" : "a"} ${role}.`);
472 }}
473 onAction={(id, a) => {
474 const u = users.find((x) => x.id === id)!;
475 if (a === "remove") {
476 const idx = users.findIndex((x) => x.id === id);
477 setUsers((p) => p.filter((x) => x.id !== id));
478 say(`Removed ${u.name} from Parcel.`, () => { setUsers((p) => { const n = [...p]; n.splice(idx, 0, u); return n; }); setNote(null); });
479 } else if (a === "suspend" || a === "reactivate") {
480 setUsers((p) => p.map((x) => (x.id === id ? { ...x, status: a === "suspend" ? "suspended" : "active" } : x)));
481 say(`${u.name} was ${a === "suspend" ? "suspended" : "reactivated"}.`);
482 } else if (a === "resend") say(`Invite re-sent to ${u.email}.`);
483 else say(`Opened ${u.name}’s profile.`);
484 }}
485 />
486 <div className="fd-uad-toast" data-open={note ? "" : undefined} role="status" aria-live="polite">
487 {note && (<><span>{note.text}</span>{note.undo && <button type="button" onClick={note.undo}>Undo</button>}</>)}
488 </div>
489 </main>
490 </div>
491 );
492}
493
494const demoCss = `
495:where(.fd-uad,.fd-uad *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}
496:where(.fd-uad,.fd-uad *)::before,:where(.fd-uad,.fd-uad *)::after{box-sizing:border-box}
497:where(.fd-uad svg){display:block;flex-shrink:0}
498:where(.fd-uad button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer}
499.fd-uad{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);
500 --d-bg:#F7F6F4;--d-side:#FBFAF9;--d-fg:#16151A;--d-mut:#6E6B78;--d-line:rgba(22,21,26,.08);--d-accent:#0F766E;--d-ring:rgba(15,118,110,.5);--d-card:#fff;
501 display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh;background:var(--d-bg);color:var(--d-fg);font:400 14px/20px ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.006em;-webkit-font-smoothing:antialiased}
502.fd-uad[data-fd-theme="dark"]{--d-bg:#0D0C10;--d-side:#111015;--d-fg:#F3F2F6;--d-mut:#9F9CAB;--d-line:rgba(255,255,255,.07);--d-accent:#2DD4BF;--d-ring:rgba(45,212,191,.55);--d-card:#131217}
503.fd-uad-side{display:flex;flex-direction:column;gap:20px;padding:16px 12px;background:var(--d-side);box-shadow:inset -1px 0 0 var(--d-line)}
504.fd-uad-ws{display:flex;align-items:center;gap:10px;padding:4px 8px}
505.fd-uad-mark{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--d-accent);color:var(--d-card);font-weight:700;font-size:14px}
506.fd-uad-ws b{display:block;font-weight:600;line-height:16px}.fd-uad-ws small{display:block;font-size:12px;line-height:16px;color:var(--d-mut)}
507.fd-uad-side nav{display:flex;flex-direction:column;gap:2px}
508.fd-uad-side nav a{display:flex;align-items:center;height:32px;padding:0 10px;border-radius:8px;font-weight:500;color:var(--d-mut);transition:background-color 120ms,color 120ms}
509.fd-uad-side nav a:hover{background:var(--d-line);color:var(--d-fg)}.fd-uad-side nav a[aria-current]{background:var(--d-line);color:var(--d-fg)}
510.fd-uad a:focus-visible,.fd-uad-theme:focus-visible,.fd-uad-invite:focus-visible,.fd-uad-toast button:focus-visible{outline:2px solid var(--d-ring);outline-offset:2px}
511.fd-uad-theme{margin-top:auto;height:32px;padding:0 10px;border-radius:8px;text-align:left;font-size:13px;font-weight:500;color:var(--d-mut)}.fd-uad-theme:hover{background:var(--d-line);color:var(--d-fg)}
512.fd-uad-main{min-width:0;padding:32px clamp(16px,3vw,40px) 80px;max-width:1040px;width:100%}
513.fd-uad-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
514.fd-uad-head h1{font-size:24px;line-height:32px;font-weight:600;letter-spacing:-.025em}.fd-uad-head p{margin-top:4px;color:var(--d-mut)}
515.fd-uad-invite{display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 14px;border-radius:10px;background:var(--d-accent);color:#fff;font-size:13px;font-weight:500;line-height:1;box-shadow:0 1px 2px rgba(15,118,110,.4),inset 0 1px 0 rgba(255,255,255,.18);transition:transform 240ms var(--fd-spring),filter 120ms}
516.fd-uad[data-fd-theme="dark"] .fd-uad-invite{color:#042F2B}
517.fd-uad-invite:hover{filter:brightness(1.08)}.fd-uad-invite:active{transform:scale(.97)}
518.fd-uad-seats{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:24px 0;padding:16px 20px;border-radius:14px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line)}
519.fd-uad-seats b{font-weight:600;font-variant-numeric:tabular-nums}.fd-uad-seats small{display:block;font-size:13px;color:var(--d-mut)}
520.fd-uad-meter{flex:0 1 220px;width:220px;height:8px;border-radius:9999px;background:var(--d-line);overflow:hidden}
521.fd-uad-meter i{display:block;height:100%;border-radius:inherit;background:var(--d-accent);transition:width 420ms var(--fd-ease-out)}
522.fd-uad-toast{position:fixed;left:50%;bottom:24px;z-index:900;display:flex;align-items:center;gap:16px;min-height:44px;padding:0 8px 0 16px;border-radius:12px;background:var(--d-fg);color:var(--d-bg);font-size:13px;font-weight:500;box-shadow:0 12px 32px -8px rgba(0,0,0,.4);opacity:0;visibility:hidden;transform:translate(-50%,12px);transition:opacity 240ms var(--fd-ease-out),transform 420ms var(--fd-ease-out),visibility 0s linear 420ms}
523.fd-uad-toast[data-open]{opacity:1;visibility:visible;transform:translate(-50%,0);transition-delay:0s}
524.fd-uad-toast button{height:28px;padding:0 10px;border-radius:8px;font-weight:600;color:inherit;background:rgba(127,127,127,.25)}
525@media (max-width:820px){.fd-uad{grid-template-columns:1fr}.fd-uad-side{flex-direction:row;align-items:center;gap:12px;padding:10px 16px;box-shadow:inset 0 -1px 0 var(--d-line)}.fd-uad-side nav{display:none}.fd-uad-theme{margin:0 0 0 auto}.fd-uad-meter{display:none}.fd-uad-main{padding-top:24px}}
526@media (prefers-reduced-motion:reduce){.fd-uad *{transition-duration:1ms!important}}
527`;

More Tables. Same file contract.

Data Table

Sortable, selectable, paginated table with sticky header, bulk actions, empty and loading states.

Expandable Table

Rows that open into rich detail panels with a smooth height animation, shown with a shipment route timeline.

Leaderboard Table

Editorial league standings with rank-change arrows, zone markers, form dots and rows that FLIP into place.