who someone is: a field that finds a person by any of their names, or adds one
npx shadcn add @ag/people-pickerAlready installed? Preview an update
npx shadcn add @ag/people-picker --dry-runYour 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.
also called user picker
shelf controls
uses scrims, avatar, fuzzy, input, material, person, scroll-area, search
the demo above, verbatim
"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
"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>
)
}