A members panel with inline role popovers, presence dots, height-collapse row removal, pending invites with resend, and a multi-email chips input.
npx shadcn@latest add @paragon/team-membersAlso installs: button
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Send, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
/* ------------------------------------------------------------------ */
type Role = "Admin" | "Member" | "Viewer";
type Status = "active" | "away" | "offline";
const ROLES: { value: Role; description: string }[] = [
{ value: "Admin", description: "Full access, including billing and members" },
{ value: "Member", description: "Can deploy and manage environments" },
{ value: "Viewer", description: "Read-only access to logs and dashboards" },
];
const STATUS_META: Record<Status, { label: string; dot: string }> = {
active: { label: "Active", dot: "bg-emerald-500" },
away: { label: "Away", dot: "bg-amber-500" },
offline: { label: "Offline", dot: "bg-muted-foreground/40" },
};
interface Member {
id: string;
name: string;
email: string;
initials: string;
hue: string;
role: Role;
status: Status;
}
const INITIAL_MEMBERS: Member[] = [
{
id: "m1",
name: "Priya Raman",
email: "priya@meridianlabs.io",
initials: "PR",
hue: "bg-violet-600/10 text-violet-700 dark:bg-violet-400/10 dark:text-violet-300",
role: "Admin",
status: "active",
},
{
id: "m2",
name: "Jonas Okafor",
email: "jonas@meridianlabs.io",
initials: "JO",
hue: "bg-sky-600/10 text-sky-700 dark:bg-sky-400/10 dark:text-sky-300",
role: "Member",
status: "active",
},
{
id: "m3",
name: "Sara Kim",
email: "sara@meridianlabs.io",
initials: "SK",
hue: "bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300",
role: "Member",
status: "away",
},
{
id: "m4",
name: "Diego Paz",
email: "diego@meridianlabs.io",
initials: "DP",
hue: "bg-amber-600/10 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300",
role: "Viewer",
status: "offline",
},
];
interface Invite {
id: string;
email: string;
sentAgo: string;
entering?: boolean;
}
const INITIAL_INVITES: Invite[] = [
{ id: "i1", email: "tom@meridianlabs.io", sentAgo: "2d ago" },
{ id: "i2", email: "elena@meridianlabs.io", sentAgo: "5d ago" },
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/* ------------------------------------------------------------------ */
/** Collapses (or expands) a row via grid-template-rows — the sanctioned height exception. */
function CollapsibleRow({
removing = false,
entering = false,
instant = false,
onCollapsed,
children,
}: {
removing?: boolean;
entering?: boolean;
instant?: boolean;
onCollapsed?: () => void;
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(!entering);
React.useEffect(() => {
if (!entering) return;
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setOpen(true));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [entering]);
const collapsed = removing || !open;
return (
<div
className={cn(
"grid",
!instant &&
"transition-[grid-template-rows,opacity] duration-200 [transition-timing-function:var(--ease-out)]",
collapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
)}
onTransitionEnd={(e) => {
if (removing && e.propertyName === "grid-template-rows") onCollapsed?.();
}}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
);
}
function RoleSelect({
member,
onChange,
popClass,
}: {
member: Member;
onChange: (role: Role) => void;
popClass: string;
}) {
const [open, setOpen] = React.useState(false);
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
aria-label={`Change role for ${member.name}, currently ${member.role}`}
className={cn(
"inline-flex h-7 w-[5.5rem] items-center justify-between gap-1 rounded-md px-2 text-[13px] font-medium",
"text-muted-foreground shadow-border outline-none",
"transition-[box-shadow,color] duration-150 ease-out hover:text-foreground hover:shadow-border-hover",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
)}
>
{member.role}
<ChevronDown
aria-hidden
className={cn(
"size-3.5 shrink-0 transition-transform duration-150 ease-out",
open && "rotate-180",
)}
/>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="end"
sideOffset={6}
className={cn("z-50 w-60 rounded-lg bg-popover p-1 shadow-overlay", popClass)}
>
{ROLES.map((role) => {
const selected = role.value === member.role;
return (
<button
key={role.value}
type="button"
aria-pressed={selected}
onClick={() => {
onChange(role.value);
setOpen(false);
}}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left outline-none",
"transition-[background-color] duration-150 ease-out hover:bg-muted focus-visible:bg-muted",
)}
>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium">
{role.value}
</span>
<span className="mt-px block text-xs text-pretty text-muted-foreground">
{role.description}
</span>
</span>
{selected && (
<Check aria-hidden className="mt-0.5 size-3.5 shrink-0" />
)}
</button>
);
})}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
/* ------------------------------------------------------------------ */
export interface TeamMembersProps extends React.ComponentProps<"section"> {
/** Heading shown above the table. */
title?: string;
}
/**
* A workspace members panel: role selection via inline popover, presence
* dots, removable rows that exit through a grid-template-rows collapse with
* fade, a pending-invites section with resend feedback, and a chips input
* that batches multiple email invites.
*/
export function TeamMembers({
title = "Members",
className,
...props
}: TeamMembersProps) {
const reducedMotion = useReducedMotion();
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const inputId = React.useId();
const [members, setMembers] = React.useState<Member[]>(INITIAL_MEMBERS);
const [removing, setRemoving] = React.useState<Set<string>>(new Set());
const [invites, setInvites] = React.useState<Invite[]>(INITIAL_INVITES);
const [resent, setResent] = React.useState<Set<string>>(new Set());
const [chips, setChips] = React.useState<string[]>([]);
const [draft, setDraft] = React.useState("");
const counter = React.useRef(0);
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
React.useEffect(() => {
const pending = timers.current;
return () => pending.forEach(clearTimeout);
}, []);
const popClass = `pop-content-${uid}`;
const removeMember = (id: string) => {
if (reducedMotion) {
setMembers((m) => m.filter((member) => member.id !== id));
return;
}
setRemoving((prev) => new Set(prev).add(id));
};
const commitChip = () => {
const value = draft.trim().replace(/,$/, "");
if (!EMAIL_RE.test(value) || chips.includes(value)) return false;
setChips((c) => [...c, value]);
setDraft("");
return true;
};
const sendInvites = () => {
const extra = EMAIL_RE.test(draft.trim()) ? [draft.trim()] : [];
const emails = [...chips, ...extra].filter(
(e) => !invites.some((i) => i.email === e),
);
if (emails.length === 0) return;
setInvites((prev) => [
...prev,
...emails.map((email) => ({
id: `new-${counter.current++}`,
email,
sentAgo: "just now",
entering: !reducedMotion,
})),
]);
setChips([]);
setDraft("");
};
const resend = (id: string) => {
setResent((prev) => new Set(prev).add(id));
timers.current.push(
setTimeout(() => {
setResent((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 1800),
);
};
return (
<section
aria-label="Team members"
className={cn("w-full max-w-2xl", className)}
{...props}
>
<style href={`paragon-team-members-${uid}`} precedence="paragon">{`
@keyframes pop-in-${uid} {
from { opacity: 0; transform: scale(0.97); }
}
.${popClass} {
transform-origin: var(--radix-popover-content-transform-origin);
animation: pop-in-${uid} 150ms var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.${popClass} { animation: none; }
}
`}</style>
<div className="rounded-xl bg-card text-card-foreground shadow-border">
<header className="flex items-baseline justify-between gap-4 px-5 pt-5 sm:px-6">
<h2 className="text-sm font-semibold">{title}</h2>
<span className="text-xs text-muted-foreground tabular-nums">
{members.length} of 25 seats
</span>
</header>
{/* Members */}
<div className="mt-2 overflow-x-auto">
<div className="min-w-[30rem]">
{members.map((member) => (
<CollapsibleRow
key={member.id}
removing={removing.has(member.id)}
onCollapsed={() =>
setMembers((m) => m.filter((x) => x.id !== member.id))
}
>
<div className="flex items-center gap-3 border-t border-border/60 px-5 py-3 sm:px-6">
<span
aria-hidden
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold",
member.hue,
)}
>
{member.initials}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{member.name}</p>
<p className="truncate text-[13px] text-muted-foreground">
{member.email}
</p>
</div>
<span className="flex w-16 shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<span
aria-hidden
className={cn(
"size-1.5 shrink-0 rounded-full",
STATUS_META[member.status].dot,
)}
/>
{STATUS_META[member.status].label}
</span>
<RoleSelect
member={member}
popClass={popClass}
onChange={(role) =>
setMembers((m) =>
m.map((x) => (x.id === member.id ? { ...x, role } : x)),
)
}
/>
<button
type="button"
aria-label={`Remove ${member.name}`}
onClick={() => removeMember(member.id)}
className={cn(
"pressable relative inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground",
"transition-[color] duration-150 ease-out hover:text-destructive",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
)}
>
<X aria-hidden className="size-3.5" />
</button>
</div>
</CollapsibleRow>
))}
</div>
</div>
{/* Pending invites */}
<div className="border-t border-border/60 px-5 py-4 sm:px-6">
<h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Pending invites
</h3>
<div className="mt-1">
{invites.map((invite) => {
const wasResent = resent.has(invite.id);
return (
<CollapsibleRow key={invite.id} entering={invite.entering}>
<div className="flex items-center gap-3 py-2">
<p className="min-w-0 flex-1 truncate text-sm">
{invite.email}
<span className="ml-2 text-xs text-muted-foreground">
invited {invite.sentAgo}
</span>
</p>
<Button
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-[13px] text-muted-foreground hover:text-foreground"
onClick={() => resend(invite.id)}
disabled={wasResent}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={wasResent ? "sent" : "resend"}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
animate={{
opacity: 1,
scale: 1,
filter: "blur(0px)",
}}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="inline-flex items-center gap-1.5"
>
{wasResent ? (
<>
<Check aria-hidden className="size-3.5" />
Sent
</>
) : (
<>
<Send aria-hidden className="size-3.5" />
Resend
</>
)}
</motion.span>
</AnimatePresence>
</Button>
</div>
</CollapsibleRow>
);
})}
</div>
</div>
{/* Invite input */}
<div className="border-t border-border/60 px-5 py-4 sm:px-6">
<label htmlFor={inputId} className="text-sm font-medium">
Invite teammates
</label>
<div className="mt-2 flex flex-wrap items-center gap-2 sm:flex-nowrap">
<div
className={cn(
"flex min-h-9 w-full min-w-0 flex-1 flex-wrap items-center gap-1.5 rounded-lg border border-input px-2 py-1.5",
"transition-[border-color,box-shadow] duration-150 ease-out",
"focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-card",
)}
>
{chips.map((chip) => (
<span
key={chip}
className="inline-flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-2 text-xs font-medium"
>
{chip}
<button
type="button"
aria-label={`Remove ${chip}`}
onClick={() => setChips((c) => c.filter((x) => x !== chip))}
className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<X aria-hidden className="size-3" />
</button>
</span>
))}
<input
id={inputId}
type="text"
inputMode="email"
autoComplete="off"
placeholder={chips.length ? "Add another…" : "colleague@company.com"}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitChip();
}
if (e.key === "Backspace" && draft === "" && chips.length) {
setChips((c) => c.slice(0, -1));
}
}}
className="h-6 min-w-28 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Button
size="sm"
className="shrink-0"
disabled={chips.length === 0 && !EMAIL_RE.test(draft.trim())}
onClick={sendInvites}
>
Send invites
</Button>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Separate multiple addresses with Enter or a comma.
</p>
</div>
</div>
</section>
);
}