Data Table
Sortable, selectable, paginated table with sticky header, bulk actions, empty and loading states.
Team members with initial avatars, inline role menu, status pills, filter chips and a hover actions menu.
npx shadcn@latest add https://freedesignmd.com/r/user-admin-table.json1/**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 row4 * 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 they11 * escape the scroll container; they close on scroll and resize.12 * Accessibility: native table semantics; the role trigger is a menu button13 * (aria-haspopup="menu", aria-expanded) opening a menuitemradio group; the14 * 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 are17 * announced in a polite live region. Row actions stay visible on touch and18 * 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-deps137 138 useLayoutEffect(() => {139 // the menu measures after mount: if it would overflow the bottom, nudge up140 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 <button220 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 <button237 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 <div270 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 <button283 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 profile299 </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 invite303 </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 workspace316 </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 member459 </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 <UserAdminTable466 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`;Sortable, selectable, paginated table with sticky header, bulk actions, empty and loading states.
Rows that open into rich detail panels with a smooth height animation, shown with a shipment route timeline.
Editorial league standings with rank-change arrows, zone markers, form dots and rows that FLIP into place.