Sections

Team Members

A members panel with inline role popovers, presence dots, height-collapse row removal, pending invites with resend, and a multi-email chips input.

Install

npx shadcn@latest add @paragon/team-members

Also installs: button

team-members.tsx

"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>
  );
}