Skip to content
goodtabs UI
BrowseBrowse components

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

IdIssueAssignee
GT-214Search ignores accents
GT-215Dark mode flashes on load
GT-216Add 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

PropTypeDefaultDescription
people{ id: string; name: string; email?: string; image?: string }[]Everyone who can be picked, in list order.
valuestring | nullThe chosen person's id, or null for unassigned. Omit to run uncontrolled.
defaultValuestring | nullnullFirst value when uncontrolled.
onValueChange(value: string | null) => voidCalled with the new id, or null when Unassigned is chosen.
placeholderstring"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