Assignee Picker
An avatar stack you click to assign people; a searchable list toggles assignees and the stack reflows live.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/assignee-picker.json'use client'
import { useEffect, useId, useRef, useState } from 'react'
import './assignee-picker.css'
export interface Teammate { id: string; name: string }
export interface AssigneePickerProps {
people: Teammate[]
defaultValue?: string[]
onValueChange?: (ids: string[]) => void
max?: number
fill?: 'dark' | 'light'
className?: string
}
const ini = (n: string) => n.split(' ').map((w) => w[0]).join('').slice(0, 2)
/** Avatar stack + searchable people toggle list. */
export function AssigneePicker({ people, defaultValue = [], onValueChange, max = 3, fill = 'dark', className }: AssigneePickerProps) {
const [v, setV] = useState(defaultValue)
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const [act, setAct] = useState(0)
const trig = useRef<HTMLButtonElement>(null)
const id = useId()
const sel = people.filter((p) => v.includes(p.id))
const list = people.filter((p) => p.name.toLowerCase().includes(q.toLowerCase()))
const toggle = (pid: string) => { const n = v.includes(pid) ? v.filter((x) => x !== pid) : [...v, pid]; setV(n); onValueChange?.(n) }
useEffect(() => { if (!open) setQ('') }, [open])
return (
<div className={['sd-asg', className].filter(Boolean).join(' ')} data-fill={fill} onKeyDown={(e) => { if (e.key === 'Escape' && open) { setOpen(false); trig.current?.focus() } }} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false) }}>
<button ref={trig} type="button" className="sd-asg__trig" aria-haspopup="listbox" aria-expanded={open} aria-label={sel.length ? `Assigned to ${sel.map((p) => p.name).join(', ')}. Change.` : 'Assign people'} onClick={() => setOpen(!open)}>
<span className="sd-asg__stack" aria-hidden>
{sel.length ? sel.slice(0, max).map((p) => <span key={p.id} className="sd-asg__av">{ini(p.name)}</span>) : <span className="sd-asg__av" data-empty="">+</span>}
{sel.length > max && <span className="sd-asg__av" data-more="">+{sel.length - max}</span>}
</span>
<span className="sd-asg__lab">{sel.length ? (sel.length === 1 ? sel[0].name : `${sel.length} assignees`) : 'Assign'}</span>
</button>
{open && (
<div className="sd-asg__pop">
<input
className="sd-asg__q"
autoFocus
role="combobox"
aria-expanded="true"
aria-controls={`${id}-l`}
aria-activedescendant={list[act] ? `${id}-${act}` : undefined}
aria-label="Search people"
placeholder="Search people"
value={q}
onChange={(e) => { setQ(e.target.value); setAct(0) }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') (e.preventDefault(), setAct((a) => Math.min(list.length - 1, a + 1)))
if (e.key === 'ArrowUp') (e.preventDefault(), setAct((a) => Math.max(0, a - 1)))
if (e.key === 'Enter' && list[act]) (e.preventDefault(), toggle(list[act].id))
}}
/>
<div role="listbox" id={`${id}-l`} aria-multiselectable="true" aria-label="People">
{list.map((p, i) => (
<div key={p.id} id={`${id}-${i}`} role="option" aria-selected={v.includes(p.id)} className="sd-asg__opt" data-active={i === act || undefined} onMouseEnter={() => setAct(i)} onMouseDown={(e) => { e.preventDefault(); toggle(p.id) }}>
<span className="sd-asg__av" aria-hidden>{ini(p.name)}</span>{p.name}
{v.includes(p.id) && <svg viewBox="0 0 16 16" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg>}
</div>
))}
</div>
</div>
)}
</div>
)
}.sd-asg { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-asg[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-asg button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-asg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-asg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-asg { position: relative; display: inline-block; align-self: start; margin-top: 40px; }
.sd-asg__trig { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 12px 0 8px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg) !important; }
.sd-asg__trig:hover, .sd-asg__trig[aria-expanded='true'] { box-shadow: inset 0 0 0 1px var(--mute); }
.sd-asg__stack { display: flex; }
.sd-asg__av { display: grid; place-items: center; width: 28px; height: 28px; margin-left: -8px; border-radius: 99px; background: var(--soft); box-shadow: 0 0 0 2px var(--bg); font-size: 10.5px; font-weight: 650; animation: sd-asg-pop 260ms cubic-bezier(0.3, 1.6, 0.4, 1); flex: none; }
.sd-asg__av:first-child { margin-left: 0; }
.sd-asg__av[data-more] { background: var(--strong); color: var(--on); }
.sd-asg__av[data-empty] { background: none; box-shadow: inset 0 0 0 1.5px var(--mute); color: var(--mute); border: 0; outline: 1px dashed transparent; }
@keyframes sd-asg-pop { from { transform: scale(0.4); opacity: 0; } }
.sd-asg__lab { font-size: 13px; color: var(--mute); white-space: nowrap; }
.sd-asg__pop { position: absolute; top: calc(100% + 6px); left: 0; min-width: 220px; padding: 5px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -16px rgb(0 0 0 / 0.55); z-index: 4; animation: sd-asg-in 160ms ease; }
@keyframes sd-asg-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-asg__q { width: 100%; height: 34px; padding: 0 9px; margin-bottom: 4px; border: 0; border-radius: 8px; outline: 0; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; }
.sd-asg__opt { display: flex; align-items: center; gap: 9px; height: 36px; padding: 0 8px; border-radius: 8px; font-size: 13.5px; cursor: pointer; }
.sd-asg__opt[data-active] { background: var(--soft); }
.sd-asg__opt .sd-asg__av { margin: 0; width: 24px; height: 24px; animation: none; box-shadow: none; }
.sd-asg__opt svg { margin-left: auto; width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-asg *, .sd-asg *::before, .sd-asg *::after { animation: none !important; transition: none !important; } }Overview
Assigned people show as an overlapping avatar stack with a +N when it overflows. Clicking opens a searchable list where each person toggles on or off, and the stack updates behind the menu as you go.
Unassigned, it shows a dashed “Assign” circle.
When to use
- Tasks, tickets, documents, review requests.
When not to use
- Assigning one owner from hundreds: use a combobox.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/assignee-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/assignee-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/assignee-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/assignee-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy assignee-picker.tsx and assignee-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AssigneePicker } from '@/components/sundae/assignee-picker'
<AssigneePicker />Props
| Prop | Type | Default |
|---|---|---|
people*Team. | { id: string; name: string }[] | — |
value / defaultValueAssigned ids. | string[] | — |
onValueChangeCalled on change. | (ids: string[]) => void | — |
maxAvatars before +N. | number | 3 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The trigger names who is assigned; the list is a multi-select listbox with a search field.
- Escape closes and returns focus.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-asg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Move
- Enter
- Toggle person
- Esc
- Close
FAQ
- Is Assignee Picker free?
- Yes. Assignee Picker is free for personal and commercial projects, with full source on this page.
- How do I install Assignee Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/assignee-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Assignee Picker support keyboard and screen readers?
- The trigger names who is assigned; the list is a multi-select listbox with a search field. Escape closes and returns focus.
- Does Assignee Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Filter Chips
Forms · RefinedMulti Select
Forms · RefinedInvite Members
Forms · RefinedStandup Notes
Forms · RefinedRetro Board
Forms · RefinedOTP Field
Forms · Refined