Assignee picker
A combobox for picking a person, with avatars, search and an Unassigned option.
npx shadcn@latest add https://ui.goodtabs.co/r/assignee-picker.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/assignee-picker
Four people with emails. Priya is picked; typing filters the list.
Unassigned
Nothing picked yet: a dashed placeholder avatar and muted text.
Small, in a table row
| Id | Issue | Assignee |
|---|---|---|
| GT-214 | Search ignores accents | |
| GT-215 | Dark mode flashes on load | |
| GT-216 | Add keyboard hints to the sidebar |
The sm size with the ghost look, one picker per row.
Usage
import { AssigneePicker } from "@/components/ui/assignee-picker"
<AssigneePicker
people={[{ id: "priya", name: "Priya Nair", email: "priya@goodtabs.co" }]}
value={assignee}
onValueChange={setAssignee}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| people | { id: string; name: string; email?: string; image?: string }[] | Everyone who can be picked, in list order. | |
| value | string | null | The chosen person's id, or null for unassigned. Omit to run uncontrolled. | |
| defaultValue | string | null | null | First value when uncontrolled. |
| onValueChange | (value: string | null) => void | Called with the new id, or null when Unassigned is chosen. | |
| placeholder | string | "Unassigned" | Trigger and list text for no one. |
| size | "sm" | "default" | "default" | Trigger height: 28px or 32px. |
| variant | "outline" | "ghost" | "outline" | Look of the trigger. |
Source
assignee-picker/assignee-picker.tsx
"use client";
import * as React from "react";
import { ChevronDownIcon, UserRoundIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export type Person = {
id: string;
name: string;
email?: string;
image?: string;
};
type AssigneePickerProps = Omit<
React.ComponentProps<typeof Button>,
"value" | "defaultValue" | "size" | "variant" | "children"
> & {
/** Everyone who can be picked. */
people: Person[];
/** The chosen person's id, or null for unassigned. */
value?: string | null;
/** First value when uncontrolled. */
defaultValue?: string | null;
/** Called with the new id, or null when Unassigned is chosen. */
onValueChange?: (value: string | null) => void;
/** Trigger and list text for no one. */
placeholder?: string;
/** Trigger height: 28px or 32px. */
size?: "sm" | "default";
/** Look of the trigger. */
variant?: "outline" | "ghost";
};
function initials(name: string) {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function PersonAvatar({ person }: { person: Person }) {
return (
<Avatar className="size-5">
{person.image ? <AvatarImage src={person.image} alt="" /> : null}
<AvatarFallback className="text-xs leading-none font-medium text-foreground/70">
{initials(person.name)}
</AvatarFallback>
</Avatar>
);
}
/**
* A combobox for picking a person. Popover and Command underneath.
* The trigger shows the avatar and name; the list filters as you type and starts with Unassigned.
*/
export function AssigneePicker({
people,
value,
defaultValue = null,
onValueChange,
placeholder = "Unassigned",
size = "default",
variant = "outline",
className,
...props
}: AssigneePickerProps) {
const [open, setOpen] = React.useState(false);
const [internal, setInternal] = React.useState<string | null>(defaultValue);
const current = value === undefined ? internal : value;
const selected = current ? people.find((p) => p.id === current) : undefined;
const choose = (next: string | null) => {
if (value === undefined) setInternal(next);
onValueChange?.(next);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
data-slot="assignee-picker"
data-empty={selected ? undefined : ""}
className={cn("max-w-full justify-start pl-1.5", size === "sm" ? "pr-1.5" : "pr-2", className)}
{...props}
>
{selected ? (
<PersonAvatar person={selected} />
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-input text-muted-foreground">
<UserRoundIcon className="size-3" aria-hidden="true" />
</span>
)}
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.name : placeholder}
</span>
<ChevronDownIcon className="ml-auto text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 gap-0 rounded-md border p-0 shadow-md ring-0">
<Command className="rounded-md!">
<CommandInput placeholder="Search people" />
<CommandList>
<CommandEmpty className="text-muted-foreground">No one found.</CommandEmpty>
<CommandGroup>
<CommandItem value="__unassigned" data-checked={!selected} onSelect={() => choose(null)} className="h-8">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-input text-muted-foreground">
<UserRoundIcon className="size-3" aria-hidden="true" />
</span>
<span className="truncate">{placeholder}</span>
</CommandItem>
{people.map((person) => (
<CommandItem
key={person.id}
value={`${person.name} ${person.email ?? ""}`}
data-checked={selected?.id === person.id}
onSelect={() => choose(person.id)}
className="h-8"
>
<PersonAvatar person={person} />
<span className="truncate">{person.name}</span>
{person.email ? (
<span className="truncate text-xs text-muted-foreground group-data-selected/command-item:text-foreground/70">
{person.email}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
For AI agents
Hand your agent this page, the markdown at /components/assignee-picker/prompt.md, or the Copy AI prompt button above.
When to use
- Assigning an issue, task or review to one person from a known team
- In a table cell, issue sidebar or form where the current person should read at a glance
- When the list is long enough that typing to filter helps
When not to use
- For picking several people at once; build a multi-select on Command instead
- For values that are not people, use a plain select or combobox
- For fewer than three options with no avatars; a select is lighter
Agent notes
- It takes onValueChange, so the component rendering it must be a client component
- Controlled with value and onValueChange, or uncontrolled with defaultValue; null means unassigned in both
- The trigger is a Button with role combobox; give it a width with className or it sizes to the name
- Avatars fall back to initials; pass image for a photo. Filtering matches name and email