← ui

people-picker

ui

who someone is: a field that finds a person by any of their names, or adds one

npx shadcn add @ag/people-picker
Already installed? Preview an update
npx shadcn add @ag/people-picker --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: the popup is a frame that stays whole; the list inside it scrolls and fades its cut edges. Before, the popup scrolled with a hard cut.
Updated 2026-10-12 · release notes

also called user picker

shelf controls

uses scrims, avatar, fuzzy, input, material, person, scroll-area, search

the demo above, verbatim

people-picker.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { shortName } from "@/registry/base-nova/lib/person"
import {
  PeoplePicker,
  PersonFace,
  type Pickable,
} from "@/registry/base-nova/ui/people-picker"

// #region people
// The people a page knows: the web's Person with the page's own id. One has no
// picture yet, and wears their initial.
const PEOPLE: Pickable[] = [
  {
    id: "aldrin",
    name: "Buzz Aldrin",
    short: "Aldrin",
    avatar: "/apollo-11/aldrin.png",
  },
  {
    id: "armstrong",
    name: "Neil Armstrong",
    short: "Armstrong",
    avatar: "/apollo-11/armstrong.png",
  },
  {
    id: "kennedy",
    name: "John F. Kennedy",
    short: "Kennedy",
    avatar: "/kennedy/kennedy.png",
  },
  { id: "hamilton", name: "Margaret Hamilton", short: "Hamilton" },
]
// #endregion

// #region name
function NameAFace() {
  const [people, setPeople] = React.useState(PEOPLE)
  const [who, setWho] = React.useState<string | null>(null)
  return (
    <PeoplePicker
      label="who is speaking"
      people={people}
      value={who}
      onPick={setWho}
      onClear={() => setWho(null)}
      onCreate={(name) => {
        const id = name.toLocaleLowerCase().replace(/\W+/g, "-")
        setPeople((all) => [...all, { id, name }])
        setWho(id)
      }}
      className="w-40"
    />
  )
}
// #endregion

// #region guess
function ConfirmAGuess() {
  const [who, setWho] = React.useState<string | null>(null)
  const [refused, setRefused] = React.useState<string[]>([])
  // The page's best guess, and a reason; refused, the next one.
  const guess = ["armstrong", "aldrin"].find((id) => !refused.includes(id))
  return (
    <PeoplePicker
      label="who this face is"
      people={PEOPLE}
      value={who}
      onPick={setWho}
      onClear={() => setWho(null)}
      clearLabel={(p) => `not ${shortName(p)}`}
      guess={guess ? { id: guess, why: "0.21 away" } : undefined}
      onRefuse={(id) => setRefused((r) => [...r, id])}
      className="w-40"
    />
  )
}
// #endregion

// #region faces
function Faces() {
  return (
    <div className="flex items-center gap-3">
      {PEOPLE.map((p) => (
        <PersonFace key={p.id} person={p} size="md" />
      ))}
    </div>
  )
}
// #endregion

// #region glass
function OnAPicture() {
  const [who, setWho] = React.useState<string | null>("aldrin")
  return (
    <div className="grid place-items-center rounded-2xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-12">
      <PeoplePicker
        material="glass"
        scheme="dark"
        label="who is speaking"
        people={PEOPLE}
        value={who}
        onPick={setWho}
        onClear={() => setWho(null)}
        className="w-44"
      />
    </div>
  )
}
// #endregion

export default function PeoplePickerDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="name"
        glance
        title="name a face"
        description="type any of their names; a name nobody has makes someone new. × takes it back, the chip itself changes it."
        with="people name"
      >
        <NameAFace />
      </Sample>
      <Sample
        name="guess"
        title="a guess to confirm"
        description="✓ names them, no refuses them and the next guess comes."
        with="people guess"
      >
        <ConfirmAGuess />
      </Sample>
      <Sample
        name="faces"
        title="faces, with or without a picture"
        with="people faces"
      >
        <Faces />
      </Sample>
      <Sample name="glass" title="in glass, over a picture" with="people glass">
        <OnAPicture />
      </Sample>
    </div>
  )
}

the source, every file an install writes

people-picker.tsx
"use client"

import { Combobox } from "@base-ui/react/combobox"
import { Check, Plus, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { fuzzyFilter, type Range } from "@/registry/base-nova/lib/fuzzy"
import { type Person, shortName } from "@/registry/base-nova/lib/person"
import {
  AVATAR_SIZES,
  Avatar,
  type AvatarSize,
} from "@/registry/base-nova/ui/avatar"
import { Input } from "@/registry/base-nova/ui/input"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { Highlight } from "@/registry/base-nova/ui/search"

// WHO SOMEONE IS, ONE PERSON: chip-input's single-choice partner, for people. Named,
// it is their face and short name as one chip (pressed, it opens to change; its × takes
// the name back, when the page says what taking it back means). Unnamed, it is a field
// that finds anyone by any of their names (lib/fuzzy, the letters it matched lit) and,
// when the page can make people, offers someone new of what was typed as the list's
// last row. Choosing is saving: there is no second step. A guess the page is fairly
// sure of rides under it, to confirm (✓) or refuse ("no").
//
// The page owns the people and what every press means: `onPick`, `onCreate`, `onClear`
// and `onRefuse` hand the work back, and a promise from any of them dims the picker
// until it settles (a server action, a write that takes a moment). Every key is the
// combobox's (Base UI): arrows walk the list, Enter picks, Escape closes or, while
// changing a name, keeps the one there was. Two materials (`material`): plain, the
// step surface, the input's well and the page's popover; glass, pills and a pane of
// the material's glass.

/** A person the picker can name: the web's Person, with the id the page keeps. */
export interface Pickable extends Person {
  id: string
}

export interface PeoplePickerProps {
  /** Everyone it can name. */
  people: readonly Pickable[]
  /** Who it names now, by id; null for nobody. */
  value: string | null
  /** Someone chosen from the list (or a guess confirmed). */
  onPick: (id: string) => void | Promise<void>
  /** Someone new, from what was typed: offered only when given. */
  onCreate?: (name: string) => void | Promise<void>
  /** The name taken back (×), handed who it was: offered only when given. */
  onClear?: (person: Pickable) => void | Promise<void>
  /** What × does, said for its tooltip and a screen reader ("not Kennedy"). */
  clearLabel?: (person: Pickable) => string
  /** A suggestion while nobody is named, by id, and why (its tooltip). */
  guess?: { id: string; why?: string }
  /** The guess refused ("no"): offered only when given. */
  onRefuse?: (id: string) => void | Promise<void>
  /** Names the field for a screen reader ("who the face is"). */
  label: string
  placeholder?: string
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's;
   *  dark over a picture, its words and its list (portalled) with it. */
  scheme?: Scheme
  className?: string
}

/** Plain: the chip on the step surface, the list the page's popover. Glass: a pill of
 *  the material's glass for the chip, the list a pane of it, its rows lit in the
 *  text's own colour. The field is the input's, in either. */
const FACE: Record<Material, { chip: string; list: string; item: string }> = {
  plain: {
    chip: "rounded-md bg-foreground/8",
    list: "rounded-lg bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10",
    item: "rounded-md data-highlighted:bg-foreground/8",
  },
  glass: {
    chip: "pr-1 [--glass-radius:16px]",
    list: "[--glass-radius:14px] [--glass-blur:14px]",
    item: "rounded-[10px] data-highlighted:bg-foreground/12",
  },
}

type Row =
  | { kind: "known"; person: Pickable; ranges: Range[] }
  | { kind: "new"; text: string }

const rowKey = (r: Row) => (r.kind === "known" ? r.person.id : `new:${r.text}`)
const fold = (s: string) => s.trim().toLocaleLowerCase()

/** The chip a named person is, and a row of the list: one line, face first. */
const CHIP = "flex h-8 max-w-full items-center gap-1.5 pr-0.5 pl-1"
const ITEM =
  "flex cursor-default items-center gap-2 px-2 py-1.5 text-[13px] outline-none"
/** A face with no picture is its initial, this share of the face's side tall. */
const LETTER = 0.44
/** The initial's size for a face of `side` (the avatar's, in rem). */
function letterOf(side: string): string {
  if (!side.endsWith("rem"))
    throw new Error(`people-picker: a face of ${side}, not rem`)
  return `${(parseFloat(side) * LETTER).toFixed(3)}rem`
}
/** A quiet word beside the field: the guess and its "no". */
const QUIET = "font-mono text-muted-foreground text-xs hover:text-foreground"

/** A person's face: their picture, else the first letter of their name on the step
 *  surface, at the avatar's own sizes. */
export function PersonFace({
  person,
  size = "sm",
  className,
}: {
  person: Person
  size?: AvatarSize
  className?: string
}) {
  if (person.avatar)
    return (
      <Avatar
        src={person.avatar}
        alt={person.name}
        size={size}
        className={className}
      />
    )
  return (
    <span
      role="img"
      aria-label={person.name}
      data-slot="people-picker-initial"
      className={cn(
        "inline-grid size-(--ag-avatar-size) flex-none place-items-center rounded-full bg-foreground/10 font-medium text-(length:--ag-people-picker-letter) text-foreground/70 uppercase",
        className,
      )}
      style={
        {
          "--ag-avatar-size": AVATAR_SIZES[size],
          "--ag-people-picker-letter": letterOf(AVATAR_SIZES[size]),
        } as React.CSSProperties
      }
    >
      {person.name.slice(0, 1)}
    </span>
  )
}

export function PeoplePicker({
  people,
  value,
  onPick,
  onCreate,
  onClear,
  clearLabel = (p) => `remove ${shortName(p)}`,
  guess,
  onRefuse,
  label,
  placeholder = "who?",
  material,
  scheme,
  className,
}: PeoplePickerProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the picker; its field, chip
  // and list wear what it reads.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
  const face = FACE[m]
  const glass = m === "glass" ? glassVariants({ shape: "surface" }) : ""
  // In its scheme: dark over a picture, its words and its list (portalled, so it
  // wears the scheme itself) take the dark theme's ink.
  const ink = cn(schemeOf(s), s === "dark" && "text-foreground")
  const [pending, start] = React.useTransition()
  const [query, setQuery] = React.useState("")
  const [changing, setChanging] = React.useState(false)
  const current = people.find((p) => p.id === value) ?? null
  const guessed =
    !current && guess ? (people.find((p) => p.id === guess.id) ?? null) : null
  if (guess && !current && !guessed)
    throw new Error(`people-picker: the guess "${guess.id}" is nobody listed`)

  const rows = React.useMemo((): Row[] => {
    const found: Row[] = fuzzyFilter(people, query, (p) => [
      p.name,
      p.short ?? "",
      p.id,
    ]).map((f) => ({
      kind: "known",
      person: f.item,
      ranges: f.ranges[0] ?? [],
    }))
    const text = query.trim()
    const taken = people.some(
      (p) =>
        fold(p.name) === fold(text) ||
        fold(p.short ?? "") === fold(text) ||
        p.id === fold(text),
    )
    return onCreate && text && !taken
      ? [...found, { kind: "new", text }]
      : found
  }, [people, query, onCreate])

  const act = (work: () => void | Promise<void>) =>
    start(async () => {
      await work()
      setQuery("")
      setChanging(false)
    })
  const pick = (row: Row) =>
    act(() =>
      row.kind === "new"
        ? (onCreate as NonNullable<typeof onCreate>)(row.text)
        : onPick(row.person.id),
    )

  return (
    <div
      ref={here}
      data-slot="people-picker"
      data-material={m}
      className={cn(
        "flex flex-col items-center gap-1.5",
        ink,
        pending && "pointer-events-none opacity-50",
        className,
      )}
    >
      {current && !changing ? (
        <span
          data-slot="people-picker-chip"
          className={cn(glass, CHIP, face.chip)}
        >
          <button
            type="button"
            onClick={() => setChanging(true)}
            title={`${current.name}: change`}
            className="flex min-w-0 items-center gap-1.5"
          >
            <PersonFace person={current} />
            <span className="truncate text-sm">{shortName(current)}</span>
          </button>
          {onClear && (
            <button
              type="button"
              onClick={() => act(() => onClear(current))}
              aria-label={clearLabel(current)}
              title={clearLabel(current)}
              className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/40 hover:text-foreground [&_svg]:size-3.5"
            >
              <X />
            </button>
          )}
        </span>
      ) : (
        <Combobox.Root
          items={rows}
          filteredItems={rows}
          value={null}
          onValueChange={(row: Row | null) => row && pick(row)}
          inputValue={query}
          onInputValueChange={setQuery}
          isItemEqualToValue={(a: Row, b: Row) => rowKey(a) === rowKey(b)}
          itemToStringLabel={(r: Row) =>
            r.kind === "new" ? r.text : r.person.name
          }
        >
          {/* The field is the registry's input, in the picker's material: its well
              plain, its pill of glass. */}
          <Combobox.Input
            data-slot="people-picker-field"
            render={
              <Input
                material={m}
                scheme={s}
                className="w-full [&_input]:text-center"
              />
            }
            aria-label={label}
            placeholder={placeholder}
            spellCheck={false}
            autoFocus={changing}
            onBlur={() => {
              if (!query.trim()) setChanging(false)
            }}
            onKeyDown={(e) => {
              if (e.key === "Escape" && changing) setChanging(false)
            }}
          />
          <Combobox.Portal>
            <Combobox.Positioner sideOffset={4} align="center" className="z-50">
              <Combobox.Popup
                data-slot="people-picker-list"
                data-material={m}
                // The frame stays whole; the list inside it scrolls, its cut
                // edges fading where there is more (scroll-area).
                className={cn(
                  glass,
                  "flex max-h-[min(20rem,var(--available-height))] w-64 flex-col p-1 outline-none",
                  face.list,
                  ink,
                )}
              >
                <Combobox.Empty className="px-2 py-1.5 text-[12px] text-foreground/45 empty:hidden">
                  {people.length
                    ? "nobody matches"
                    : onCreate
                      ? "type a name to add someone"
                      : "nobody to name"}
                </Combobox.Empty>
                <ScrollArea
                  render={
                    <Combobox.List>
                      {(row: Row) => (
                        <Combobox.Item
                          key={rowKey(row)}
                          value={row}
                          className={cn(ITEM, face.item)}
                        >
                          {row.kind === "new" ? (
                            <>
                              <span className="grid size-6 place-items-center text-foreground/55 [&_svg]:size-3.5">
                                <Plus />
                              </span>
                              <span className="min-w-0 truncate">
                                new person “{row.text}”
                              </span>
                            </>
                          ) : (
                            <>
                              <PersonFace person={row.person} />
                              <span className="min-w-0 truncate">
                                <Highlight
                                  text={row.person.name}
                                  ranges={row.ranges}
                                />
                              </span>
                              {row.person.short &&
                                row.person.short !== row.person.name && (
                                  <span className="ml-auto shrink-0 font-mono text-[10px] text-foreground/40">
                                    {row.person.short}
                                  </span>
                                )}
                            </>
                          )}
                        </Combobox.Item>
                      )}
                    </Combobox.List>
                  }
                />
              </Combobox.Popup>
            </Combobox.Positioner>
          </Combobox.Portal>
        </Combobox.Root>
      )}
      {guessed && guess && (
        <span
          data-slot="people-picker-guess"
          className="flex items-center gap-2"
        >
          <button
            type="button"
            onClick={() => act(() => onPick(guessed.id))}
            title={
              guess.why
                ? `${guessed.name}, ${guess.why}: yes`
                : `${guessed.name}: yes`
            }
            className={cn(QUIET, "flex items-center gap-1")}
          >
            <PersonFace person={guessed} />
            {shortName(guessed)}?
            <Check className="size-3" />
          </button>
          {onRefuse && (
            <button
              type="button"
              onClick={() => act(() => onRefuse(guessed.id))}
              aria-label={`not ${shortName(guessed)}`}
              className={QUIET}
            >
              no
            </button>
          )}
        </span>
      )}
    </div>
  )
}