Skip to content
Component installs need the registry setup
VegaStack Design

People Picker

The one picker for people and teams — single or multiple, with avatar rows, "(you)", team sizes, skeleton loading and footer actions.

Status
stable
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-picker

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/people-picker

Run 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 — search returns 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 a RecordChip) and PeoplePickerMenu (a DropdownMenu submenu, "Assign ›"). FilterBarPeopleFacet puts the same body under a filter chip.

Anatomy

PeoplePicker — data-slot="people-picker" | "people-picker-avatars" | "people-picker-popover"
PeoplePickerContent — data-slot="people-picker-content" | "people-picker-item"
PeoplePickerMenu — data-slot="people-picker-item" | "people-picker-menu"

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

PropTypeDefaultDescription
onValueChange*(value: PeoplePickerValue<Multiple>) => void—Called with the new choice.
value*PeoplePickerValue<Multiple>—The chosen option — or options, with multiple (controlled).
actionsreadonly PeoplePickerAction[][]Static actions in the footer under the results.
align"center" | "end" | "start""start"Popover alignment against the trigger.
aria-describedbystring—Ids of elements that describe the trigger.
aria-invalidboolean—Mark the trigger invalid.
aria-labelstringthe placeholder or the choiceThe trigger's accessible name when no label names it.
classNamestring—Classes for the trigger.
contentClassNamestring—Classes for the popover.
defaultSearchstring—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.
disabledbooleanfalseDisable the trigger.
emptyTextstring"No people found"Shown when nothing matches.
excludereadonly string[][]Ids never offered (a member cannot manage themselves).
headingstring—A label over the rows ("Your agents").
idstring—The trigger's id, for a <label htmlFor>.
inactiveLabelstring"Inactive"The badge on an inactive person (with includeInactive).
includeInactivebooleanfalseList people with active: false, badged "Inactive".
leadingOptionsreadonly PeoplePickerOption[][]Options always listed first and never filtered out — "Unassigned" in a filter. Give each an icon.
multipleMultiplefalseChoose 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.
openboolean—Open state (controlled).
optionsreadonly PeoplePickerOption[]—A fixed list instead of search, filtered here by name, email and description.
placeholderstring"Select a person", or "Add people…" with `multiple`The trigger's text while nothing is chosen.
refReact.Ref<HTMLButtonElement>—Ref to the trigger button.
searchPeoplePickerSearch—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.
searchPlaceholderstring"Search people"The search field's placeholder and accessible name.
selectedreadonly 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.
viewerIdstring | null—The viewer's id: their row reads "(you)" and is listed first.
youLabelstring"(you)"The viewer's marker after their name.

Data attributes and CSS variables on PeoplePicker

AttributeValues
data-placeholder""
data-sizemirrors a prop or state value
data-slot"people-picker" | "people-picker-avatars" | "people-picker-popover"
data-variantmirrors a prop or state value

PeoplePickerListProps

The list props every host shares.

PropTypeDefaultDescription
actionsreadonly PeoplePickerAction[][]Static actions in the footer under the results.
defaultSearchstring—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.
emptyTextstring"No people found"Shown when nothing matches.
excludereadonly string[][]Ids never offered (a member cannot manage themselves).
headingstring—A label over the rows ("Your agents").
inactiveLabelstring"Inactive"The badge on an inactive person (with includeInactive).
includeInactivebooleanfalseList people with active: false, badged "Inactive".
leadingOptionsreadonly PeoplePickerOption[][]Options always listed first and never filtered out — "Unassigned" in a filter. Give each an icon.
optionsreadonly PeoplePickerOption[]—A fixed list instead of search, filtered here by name, email and description.
searchPeoplePickerSearch—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.
searchPlaceholderstring"Search people"The search field's placeholder and accessible name.
selectedreadonly 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.
viewerIdstring | null—The viewer's id: their row reads "(you)" and is listed first.
youLabelstring"(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.

PropTypeDefaultDescription
id*string—A stable id, unique across people and teams.
name*string—The name shown, and the avatar's initials.
activebooleantruefalse for someone who cannot be chosen — deactivated, or invited and not yet joined. Left out of the list unless includeInactive.
badgeReact.ReactNode—A status after the name, such as "Inactive" — a string is a small muted outline badge.
descriptionstring | null—The muted second line instead of the email — a role, a department. A team without one reads "Team · N people" from memberCount.
disabledReasonstring | 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.
emailstring | null—The muted line under the name, and the initials when there is no name; omit for someone without an account.
hueAvatarHue | null—The person's colour behind their initials — one of the ten tag hues; none is the muted fallback.
iconReact.ReactNode—An icon in the avatar's place, for an option that is not a person ("Unassigned" in a filter).
imagestring | null—The avatar image.
kindPersonKind"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).
memberCountnumber | null—A team's size, for its "Team · N people" line.
viastring | 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 is aria-busy while it loads.
KeyAction
EnterOpen the picker; pick the highlighted row
TypeSearch
↑ / ↓Move through results and actions
EscClose
ContractStates tested
Behaviourloading-skeleton, results, no-results, error, single, multiple, you, team, inactive-hidden, include-inactive, actions, menu, leading-options
Accessibilitylabeled, keyboard, browser-accessibility-test
Visualdefault, open, chosen, multiple, loading

Do / Don't

Do
Use PeoplePicker for every person, people-and-teams and person-filter choice.
Don't
Build a person list from Command, Combobox or SearchableSelect rows of your own.
Do
Leave deactivated and invited people out of search; opt in with includeInactive only where a past person must be found.
Don't
Offer people who cannot be assigned, added or shared with.

On this page