Assignee Picker
Inputs & Forms

Assignee Picker

Searchable member picker with initials avatars, presence dots, an Unassigned zero state, and a multi-assign facepile trigger that grows as members toggle.

Install

npx shadcn@latest add @paragon/assignee-picker

assignee-picker.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, CircleDashed, Search, UserRound } from "lucide-react";
import { cn } from "@/lib/utils";

export type Presence = "online" | "away" | "offline";

export interface AssigneeMember {
  id: string;
  name: string;
  /** Secondary line, e.g. a handle or team. */
  hint?: string;
  presence?: Presence;
}

const DEFAULT_MEMBERS: AssigneeMember[] = [
  { id: "mira", name: "Mira Chen", hint: "@mira", presence: "online" },
  { id: "jonah", name: "Jonah Reyes", hint: "@jonah", presence: "online" },
  { id: "priya", name: "Priya Natarajan", hint: "@priya", presence: "away" },
  { id: "tomas", name: "Tomás Herrera", hint: "@tomas", presence: "offline" },
  { id: "elle", name: "Elle Novak", hint: "@elle", presence: "online" },
  { id: "sam", name: "Sam Okafor", hint: "@sam", presence: "offline" },
];

function initialsOf(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "•";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

function hueOf(value: string): number {
  let hash = 0;
  for (let i = 0; i < value.length; i++) {
    hash = (hash << 5) - hash + value.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash) % 360;
}

const PRESENCE_CLASS: Record<Presence, string> = {
  online: "bg-success",
  away: "bg-warning",
  offline: "bg-muted-foreground/40",
};

function MemberAvatar({
  member,
  size = 20,
  showPresence = true,
  className,
}: {
  member: AssigneeMember;
  size?: number;
  showPresence?: boolean;
  className?: string;
}) {
  const hue = hueOf(member.name);
  return (
    <span
      className={cn("relative inline-flex shrink-0", className)}
      style={{ width: size, height: size }}
    >
      <span
        aria-hidden
        className="flex size-full items-center justify-center rounded-full font-medium select-none"
        style={{
          backgroundColor: `oklch(0.9 0.05 ${hue})`,
          color: `oklch(0.4 0.09 ${hue})`,
          fontSize: Math.max(8, Math.round(size * 0.42)),
        }}
      >
        {initialsOf(member.name)}
      </span>
      {showPresence && member.presence && (
        <span
          aria-hidden
          className={cn(
            "absolute -right-px -bottom-px size-[7px] rounded-full ring-2 ring-popover",
            PRESENCE_CLASS[member.presence],
          )}
        />
      )}
    </span>
  );
}

const assigneePickerStyles = `
@keyframes pg-assignee-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-assignee-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-assignee-in { from { opacity: 0; } }
  @keyframes pg-assignee-out { to { opacity: 0; } }
}
`;

export interface AssigneePickerProps
  extends Omit<
    React.ComponentProps<"button">,
    "value" | "defaultValue" | "onChange" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
  > {
  members?: AssigneeMember[];
  /** Toggle multi-assign: rows check on/off and the trigger shows a facepile. */
  multiple?: boolean;
  /** Controlled selected member ids (0 or 1 entries when single). */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (ids: string[]) => void;
  /** Presence dots on avatars. */
  showPresence?: boolean;
  /** Max avatars in the facepile before "+N". */
  maxFacepile?: number;
  /** Disables trigger swap motion. */
  static?: boolean;
}

/**
 * Assignee picker: searchable member list with initials avatars and
 * presence dots, an "Unassigned" zero state, and a multi-assign mode whose
 * facepile trigger grows with layout animation as members check on and
 * off. Single mode closes on select and blur-swaps the trigger to the new
 * assignee.
 */
export function AssigneePicker({
  members = DEFAULT_MEMBERS,
  multiple = false,
  value,
  defaultValue,
  onValueChange,
  showPresence = true,
  maxFacepile = 3,
  static: isStatic = false,
  className,
  disabled,
  ...props
}: AssigneePickerProps) {
  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const reduced = useReducedMotion();
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [internal, setInternal] = React.useState<string[]>(defaultValue ?? []);
  const ids = value ?? internal;
  const listRef = React.useRef<HTMLDivElement>(null);

  const commit = (next: string[]) => {
    setInternal(next);
    onValueChange?.(next);
  };

  const visible = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    return q
      ? members.filter(
          (m) =>
            m.name.toLowerCase().includes(q) ||
            m.hint?.toLowerCase().includes(q),
        )
      : members;
  }, [members, query]);

  // Row ids: "unassigned" sentinel + member ids, in visible order.
  const rowIds = React.useMemo(
    () => [
      ...(query.trim() === "" ? ["unassigned"] : []),
      ...visible.map((m) => m.id),
    ],
    [visible, query],
  );
  const [active, setActive] = React.useState<string>("unassigned");
  const activeIndex = rowIds.indexOf(active);

  React.useEffect(() => {
    if (!open) return;
    listRef.current
      ?.querySelector('[data-active="true"]')
      ?.scrollIntoView({ block: "nearest" });
  }, [active, open]);

  const pick = (rowId: string) => {
    if (rowId === "unassigned") {
      commit([]);
      setOpen(false);
      return;
    }
    if (multiple) {
      commit(
        ids.includes(rowId) ? ids.filter((i) => i !== rowId) : [...ids, rowId],
      );
      // Multi-assign keeps the panel open for further toggles.
    } else {
      commit([rowId]);
      setOpen(false);
    }
  };

  const move = (delta: number) => {
    if (rowIds.length === 0) return;
    const next =
      (Math.max(0, activeIndex) + delta + rowIds.length) % rowIds.length;
    setActive(rowIds[next]);
  };

  const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      move(1);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      move(-1);
    } else if (e.key === "Enter") {
      e.preventDefault();
      const target = activeIndex >= 0 ? rowIds[activeIndex] : rowIds[0];
      if (target) pick(target);
    }
  };

  const selectedMembers = ids
    .map((id) => members.find((m) => m.id === id))
    .filter((m): m is AssigneeMember => Boolean(m));
  const single = selectedMembers[0];
  const overflow = selectedMembers.length - maxFacepile;

  const animate = !isStatic && !reduced;
  const blur = animate ? "blur(4px)" : "blur(0px)";

  return (
    <PopoverPrimitive.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (next) {
          setQuery("");
          setActive(single ? single.id : "unassigned");
        }
      }}
    >
      <PopoverPrimitive.Trigger asChild disabled={disabled}>
        <motion.button
          type="button"
          layout={animate}
          transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          data-slot="assignee-picker"
          aria-label={
            selectedMembers.length === 0
              ? "Assignee: unassigned"
              : `Assignee: ${selectedMembers.map((m) => m.name).join(", ")}`
          }
          className={cn(
            "group inline-flex h-7 items-center gap-1.5 rounded-md border border-input bg-transparent px-1.5 text-xs font-medium whitespace-nowrap",
            "transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--ease-out)",
            "outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
            "data-[state=open]:border-ring disabled:pointer-events-none disabled:opacity-50",
            !isStatic && "active:not-disabled:scale-[0.97]",
            className,
          )}
          {...props}
        >
          {multiple ? (
            selectedMembers.length === 0 ? (
              <>
                <span className="flex size-5 items-center justify-center rounded-full border border-dashed border-border text-muted-foreground">
                  <UserRound className="size-3" aria-hidden />
                </span>
                <span className="pr-0.5 text-muted-foreground">Assign</span>
              </>
            ) : (
              <>
                <span className="flex items-center -space-x-1.5">
                  <AnimatePresence mode="popLayout" initial={false}>
                    {selectedMembers.slice(0, maxFacepile).map((m) => (
                      <motion.span
                        key={m.id}
                        layout={animate}
                        initial={{ opacity: 0, scale: 0.5, filter: blur }}
                        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                        exit={{ opacity: 0, scale: 0.5, filter: blur }}
                        transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                        className="rounded-full ring-2 ring-background"
                      >
                        <MemberAvatar member={m} size={18} showPresence={false} />
                      </motion.span>
                    ))}
                  </AnimatePresence>
                </span>
                <span className="pr-0.5 text-muted-foreground tabular-nums">
                  {overflow > 0 ? `+${overflow}` : selectedMembers.length}
                </span>
              </>
            )
          ) : (
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={single?.id ?? "unassigned"}
                layout={animate ? "position" : false}
                initial={{ opacity: 0, filter: blur }}
                animate={{ opacity: 1, filter: "blur(0px)" }}
                exit={{ opacity: 0, filter: blur, transition: { duration: 0.1 } }}
                transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
                className="flex items-center gap-1.5"
              >
                {single ? (
                  <>
                    <MemberAvatar member={single} size={18} showPresence={showPresence} />
                    <span className="pr-0.5">{single.name}</span>
                  </>
                ) : (
                  <>
                    <span className="flex size-5 items-center justify-center rounded-full border border-dashed border-border text-muted-foreground">
                      <UserRound className="size-3" aria-hidden />
                    </span>
                    <span className="pr-0.5 text-muted-foreground">Unassigned</span>
                  </>
                )}
              </motion.span>
            </AnimatePresence>
          )}
        </motion.button>
      </PopoverPrimitive.Trigger>
      {/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
          child node, and the Radix Portal enforces a single child. */}
      <style href="paragon-assignee-picker" precedence="paragon">
        {assigneePickerStyles}
      </style>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          align="start"
          sideOffset={6}
          collisionPadding={8}
          className={cn(
            "z-50 w-60 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover text-popover-foreground shadow-overlay outline-none",
            "data-[state=open]:animate-[pg-assignee-in_180ms_var(--ease-out)]",
            "data-[state=closed]:animate-[pg-assignee-out_90ms_var(--ease-exit)_forwards]",
          )}
        >
          <div className="flex items-center gap-2 border-b border-border px-2.5">
            <Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
            <input
              autoFocus
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setActive("");
              }}
              onKeyDown={onSearchKeyDown}
              placeholder={multiple ? "Add assignees…" : "Assign to…"}
              role="combobox"
              aria-expanded="true"
              aria-controls={`assignee-list-${uid}`}
              aria-activedescendant={
                activeIndex >= 0 ? `assignee-opt-${uid}-${active}` : undefined
              }
              aria-label="Filter members"
              className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
            />
          </div>
          <div
            ref={listRef}
            id={`assignee-list-${uid}`}
            role="listbox"
            aria-multiselectable={multiple || undefined}
            aria-label="Members"
            className="max-h-72 overflow-y-auto p-1"
            onPointerLeave={() => setActive("")}
          >
            {query.trim() === "" && (
              <button
                type="button"
                role="option"
                id={`assignee-opt-${uid}-unassigned`}
                aria-selected={selectedMembers.length === 0}
                data-active={active === "unassigned" || undefined}
                tabIndex={-1}
                onPointerMove={() => setActive("unassigned")}
                onClick={() => pick("unassigned")}
                className={cn(
                  "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
                  "transition-[background-color] duration-100 ease-(--ease-out)",
                  active === "unassigned" && "bg-accent text-accent-foreground",
                )}
              >
                <span className="flex size-5 items-center justify-center text-muted-foreground">
                  <CircleDashed className="size-4" aria-hidden />
                </span>
                <span className="min-w-0 flex-1 truncate text-muted-foreground">
                  Unassigned
                </span>
                <span className="flex size-3.5 shrink-0 items-center justify-center">
                  {selectedMembers.length === 0 && (
                    <Check className="size-3.5" aria-hidden />
                  )}
                </span>
              </button>
            )}
            {visible.length === 0 && (
              <p className="px-2 py-4 text-center text-xs text-muted-foreground">
                No members match.
              </p>
            )}
            {visible.map((m) => {
              const isActive = m.id === active;
              const isSelected = ids.includes(m.id);
              return (
                <button
                  key={m.id}
                  type="button"
                  role="option"
                  id={`assignee-opt-${uid}-${m.id}`}
                  aria-selected={isSelected}
                  data-active={isActive || undefined}
                  tabIndex={-1}
                  onPointerMove={() => setActive(m.id)}
                  onClick={() => pick(m.id)}
                  className={cn(
                    "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
                    "transition-[background-color] duration-100 ease-(--ease-out)",
                    isActive && "bg-accent text-accent-foreground",
                  )}
                >
                  <MemberAvatar member={m} showPresence={showPresence} />
                  <span className="min-w-0 flex-1">
                    <span className="block truncate">{m.name}</span>
                  </span>
                  {m.hint && (
                    <span className="shrink-0 truncate text-[11px] text-muted-foreground">
                      {m.hint}
                    </span>
                  )}
                  <span className="flex size-3.5 shrink-0 items-center justify-center">
                    <AnimatePresence initial={false}>
                      {isSelected && (
                        <motion.span
                          key="check"
                          className="flex"
                          initial={{ opacity: 0, scale: 0.25, filter: blur }}
                          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                          exit={{ opacity: 0, scale: 0.25, filter: blur }}
                          transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                        >
                          <Check className="size-3.5" aria-hidden />
                        </motion.span>
                      )}
                    </AnimatePresence>
                  </span>
                </button>
              );
            })}
          </div>
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  );
}