Searchable member picker with initials avatars, presence dots, an Unassigned zero state, and a multi-assign facepile trigger that grows as members toggle.
npx shadcn@latest add @paragon/assignee-picker"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>
);
}