People Picker
The one picker for people and teams — single or multiple, with avatar rows, "(you)", team sizes, skeleton loading and footer actions.
- Status
- Since
0.23.112- Accessibility pattern
- popover over a cmdk listbox; the trigger is a named button
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for People Picker before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify people-pickerInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/people-pickerRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/command, @vegastack/dropdown-menu, @vegastack/panel-search, @vegastack/person-avatar, @vegastack/popover, @vegastack/searchable-select, @vegastack/skeleton, @vegastack/use-async-search.
Usage
import {
PeoplePicker,
type PeoplePickerOption,
} from "@/components/ui/people-picker";
const [assignee, setAssignee] = React.useState<PeoplePickerOption | null>(null);
<PeoplePicker
value={assignee}
onValueChange={setAssignee}
search={(query, { cursor, signal }) => searchMembers(query, cursor, signal)}
viewerId={me.id}
/>;Every surface that chooses people composes this one component, so a row reads the same everywhere:
the avatar on the left, the name (the viewer's reads "(you)" and is listed first), then the email,
a description, or "Team · N people" in a smaller truncated line. search is a
useAsyncSearch loader — debounced, race-safe and paged on scroll (return nextCursor); pass
options instead for a fixed list. While the first page loads the list shows skeleton rows, never
a spinner.
Only active people are listed. An option with active: false (deactivated, or invited and not yet
joined) is left out unless the host passes includeInactive, which badges it "Inactive". The
host's search should leave them out as well — the picker is the second line of that rule.
Scope
- Owns: the trigger, the popover, the search state, the person and team rows, "(you)", the skeleton, paging and the footer actions.
- Does not own: the directory or who may be chosen —
searchreturns only people who can be. - Three hosts share the rows:
PeoplePicker(a field),PeoplePickerContent(the popover body for a trigger the host owns, such as aRecordChip) andPeoplePickerMenu(aDropdownMenusubmenu, "Assign ›").FilterBarPeopleFacetputs the same body under a filter chip.
Anatomy
Examples
Several people and teams
multiple: rows toggle and the popover stays open; the trigger reads the chosen names. The trigger
is exactly as tall as the PermissionMenu and Button beside it.
Sizes
size is sm, default or lg — the same heights as Button and Select.
A host-owned trigger
PeoplePickerContent inside a PopoverContent (w-72 p-0), here with a fixed list and
includeInactive.
Agents
An option with kind: "agent" and via reads "Claude Code via Manoj" with the purple bot and the
"Agent" label, so the same picker delegates work to a member's agents. heading labels the rows
("Your agents"); an option with disabledReason stays listed, disabled, with the reason as its
second line.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
onValueChange* | (value: PeoplePickerValue<Multiple>) => void | — | Called with the new choice. |
value* | PeoplePickerValue<Multiple> | — | The chosen option — or options, with multiple (controlled). |
actions | readonly PeoplePickerAction[] | [] | Static actions in the footer under the results. |
align | "center" | "end" | "start" | "start" | Popover alignment against the trigger. |
aria-describedby | string | — | Ids of elements that describe the trigger. |
aria-invalid | boolean | — | Mark the trigger invalid. |
aria-label | string | the placeholder or the choice | The trigger's accessible name when no label names it. |
className | string | — | Classes for the trigger. |
contentClassName | string | — | Classes for the popover. |
defaultSearch | string | — | The search text the list starts from instead of "" — the character that opened a cell
editor (type-to-edit). PeoplePicker also opens with it, once, on mount. |
disabled | boolean | false | Disable the trigger. |
emptyText | string | "No people found" | Shown when nothing matches. |
exclude | readonly string[] | [] | Ids never offered (a member cannot manage themselves). |
heading | string | — | A label over the rows ("Your agents"). |
id | string | — | The trigger's id, for a <label htmlFor>. |
inactiveLabel | string | "Inactive" | The badge on an inactive person (with includeInactive). |
includeInactive | boolean | false | List people with active: false, badged "Inactive". |
leadingOptions | readonly PeoplePickerOption[] | [] | Options always listed first and never filtered out — "Unassigned" in a filter. Give each an
icon. |
multiple | Multiple | false | Choose several people and teams: rows toggle, the popover stays open, and the trigger reads the chosen names. |
onOpenChange | ((open: boolean) => void) | — | Called when the popover opens or closes. |
open | boolean | — | Open state (controlled). |
options | readonly PeoplePickerOption[] | — | A fixed list instead of search, filtered here by name, email and description. |
placeholder | string | "Select a person", or "Add people…" with `multiple` | The trigger's text while nothing is chosen. |
ref | React.Ref<HTMLButtonElement> | — | Ref to the trigger button. |
search | PeoplePickerSearch | — | Finds people and teams for the typed query ("" on open), debounced and race-safe; return
nextCursor to page on scroll. Leave out anyone who cannot be chosen. |
searchPlaceholder | string | "Search people" | The search field's placeholder and accessible name. |
selected | readonly string[] | [] | Ids shown with a check (the current choice). |
size | "default" | "lg" | "sm" | "default" | The trigger's height — the same as Button and Select at each size, so a picker sits flush
in a row with them ("Add people… | Can edit | Add"). |
teamLabel | ((memberCount: number) => string) | (n) => `Team · ${n} ${n === 1 ? "person" : "people"}` | A team's second line. |
variant | "ghost" | "outline" | "outline" | ghost is the inline trigger: no border at rest. |
viewerId | string | null | — | The viewer's id: their row reads "(you)" and is listed first. |
youLabel | string | "(you)" | The viewer's marker after their name. |
Data attributes and CSS variables on PeoplePicker
| Attribute | Values |
|---|---|
data-placeholder | "" |
data-size | mirrors a prop or state value |
data-slot | "people-picker" | "people-picker-avatars" | "people-picker-popover" |
data-variant | mirrors a prop or state value |
PeoplePickerListProps
The list props every host shares.
| Prop | Type | Default | Description |
|---|---|---|---|
actions | readonly PeoplePickerAction[] | [] | Static actions in the footer under the results. |
defaultSearch | string | — | The search text the list starts from instead of "" — the character that opened a cell
editor (type-to-edit). PeoplePicker also opens with it, once, on mount. |
emptyText | string | "No people found" | Shown when nothing matches. |
exclude | readonly string[] | [] | Ids never offered (a member cannot manage themselves). |
heading | string | — | A label over the rows ("Your agents"). |
inactiveLabel | string | "Inactive" | The badge on an inactive person (with includeInactive). |
includeInactive | boolean | false | List people with active: false, badged "Inactive". |
leadingOptions | readonly PeoplePickerOption[] | [] | Options always listed first and never filtered out — "Unassigned" in a filter. Give each an
icon. |
options | readonly PeoplePickerOption[] | — | A fixed list instead of search, filtered here by name, email and description. |
search | PeoplePickerSearch | — | Finds people and teams for the typed query ("" on open), debounced and race-safe; return
nextCursor to page on scroll. Leave out anyone who cannot be chosen. |
searchPlaceholder | string | "Search people" | The search field's placeholder and accessible name. |
selected | readonly string[] | [] | Ids shown with a check (the current choice). |
teamLabel | ((memberCount: number) => string) | (n) => `Team · ${n} ${n === 1 ? "person" : "people"}` | A team's second line. |
viewerId | string | null | — | The viewer's id: their row reads "(you)" and is listed first. |
youLabel | string | "(you)" | The viewer's marker after their name. |
PeoplePickerOption
A Person (from person-avatar) with a stable id; kind: "team" draws it as a team.
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | A stable id, unique across people and teams. |
name* | string | — | The name shown, and the avatar's initials. |
active | boolean | true | false for someone who cannot be chosen — deactivated, or invited and not yet joined. Left
out of the list unless includeInactive. |
badge | React.ReactNode | — | A status after the name, such as "Inactive" — a string is a small muted outline badge. |
description | string | null | — | The muted second line instead of the email — a role, a department. A team without one reads
"Team · N people" from memberCount. |
disabledReason | string | null | — | Why this option can't be picked ("Read only — can't be delegated"): it is listed, disabled, with the reason as its second line. |
email | string | null | — | The muted line under the name, and the initials when there is no name; omit for someone without an account. |
hue | AvatarHue | null | — | The person's colour behind their initials — one of the ten tag hues; none is the muted fallback. |
icon | React.ReactNode | — | An icon in the avatar's place, for an option that is not a person ("Unassigned" in a filter). |
image | string | null | — | The avatar image. |
kind | PersonKind | "person" | A person, a team of people (a rounded-square tile with the team icon on its hue), or an agent
working for a member (a bot on the purple tag hue, named with via and an "Agent" label). |
memberCount | number | null | — | A team's size, for its "Team · N people" line. |
via | string | null | — | An agent's owner — the member it works for: "Claude Code" via "Manoj". |
Accessibility
- The trigger is a button named by its label,
aria-label, or the choice; the popover holds a search field and a listbox of options. - The footer actions are options after the results; a search never highlights them, so Enter never runs "Assign to me" after a typo.
- The skeleton rows are
aria-hidden; the list isaria-busywhile it loads.
| Key | Action |
|---|---|
| Enter | Open the picker; pick the highlighted row |
| Type | Search |
| ↑ / ↓ | Move through results and actions |
| Esc | Close |
| Contract | States tested |
|---|---|
| Behaviour | loading-skeleton, results, no-results, error, single, multiple, you, team, inactive-hidden, include-inactive, actions, menu, leading-options |
| Accessibility | labeled, keyboard, browser-accessibility-test |
| Visual | default, open, chosen, multiple, loading |
Do / Don't
Searchable Select
The Select-shaped Combobox preset — a full-width trigger, an in-panel search field, a check on the selected row, and an optional clear control.
Record Picker
Pick one or several records of the kinds the app passes in — search, kind filter, recents and refused rows — in a popover or a dialog.