Persona Picker
Choose the assistant persona a turn runs under — seeded avatar cards or a compact combobox, capability tags, the system-prompt fragment each persona quietly injects, unavailable rows that say why, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/persona-picker.jsonPrompt
Build a React + TypeScript + Tailwind "PersonaPicker" component with zod and
lucide-react, plus a shadcn Tooltip for the one icon-only affordance.
Contract
- A zod schema (`personaPickerItemSchema` in a sibling contract file) is the
single source of truth for ONE persona: { id, name, tagline, avatarSeed,
tags: string[], systemHint?, badge?, unavailable?: { reason } }.
- `avatarSeed` is a SEED, not a URL. Storing a seed is what makes the same
persona keep the same face across sessions, servers and screenshots, and it
lets the component draw a face with zero network by default. `avatarSrc?:
(persona) => string | undefined` upgrades the seed to a real image (DiceBear,
your own uploads); returning undefined keeps the built-in monogram.
- `systemHint` is the honest field: the system-prompt fragment this persona
prepends. A picker that hides it is asking the user to choose a personality by
its haircut.
- `unavailable` is an OBJECT with a required `reason`, not a boolean. A persona
the user cannot pick is only honest if it says why ("Team plan only"); making
the reason part of the type means you cannot forget it.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
LIST's own state, independent of any persona's availability: `status="error"`
means the roster failed to load, while a persona your plan can't call is a
`ready` list with an `unavailable` row in it.
- Props: items: Persona[]; status; value? (controlled, null = nothing selected);
defaultValue? (uncontrolled seed); onValueChange?(id, persona) — it hands back
the whole persona so the caller never has to look it up; variant?: "cards" |
"compact" (default "cards"); columns?: 1 | 2 | 3 (cards only); maxTags? (3,
clamped >= 1); avatarSrc?; showSystemHint? (true); onCreate?; createLabel?;
placeholder?; emptyState?; emptyMessage?; errorMessage?; onRetry?;
skeletonCount? (3, clamped >= 1); label? ("Assistant persona"); className plus
the remaining div props, ref forwarded to the root.
Behavior
- ONE selection contract, TWO interaction models, and they are not
interchangeable. `cards` is a role="radiogroup" of role="radio" tiles with a
roving tabindex: Tab lands on the chosen card, Arrow keys move focus AND
select in one step (radio semantics), Home/End jump to the ends, and the ring
WRAPS at both ends. `compact` is a role="combobox" trigger over a
role="listbox" popup driven by aria-activedescendant: focus never leaves the
trigger, ↓/↑ move the active option and CLAMP at the ends (a wrapping list
makes "am I at the bottom?" unanswerable), Enter/Space commits, Escape closes
and keeps the committed value, Tab closes and lets focus move on.
- Controlled and uncontrolled both work: `value` present = the parent owns it;
otherwise an internal state moves and `onValueChange` still fires. Committing
is the only write — browsing a list never mutates the value, so Escape
restores the selection by construction instead of from a snapshot.
- A committed id that is NOT in `items` (someone deleted the persona) is
surfaced, never silently reassigned: the cards variant prints a note naming
the missing id, the compact trigger shows the raw id over "Not in this persona
list". Auto-picking a neighbour would overwrite a user's choice with a guess.
- An `unavailable` persona stays reachable — arrow keys land on it so its reason
is announced — and simply never commits. Clicking it is a no-op, not a silent
write, and Enter inside the listbox leaves the panel open so the reason can be
re-read.
- Tags are capped at `maxTags` with an explicit "+N", and the FULL list always
reaches the accessibility tree through the row's accessible name, so folding
is a layout decision and never a loss of information.
- Selecting must not reflow: the check mark occupies its box whether or not it
is checked, so a grid of cards does not jump one pixel when the choice moves.
- The face degrades in one step: `avatarSrc` → <img>, an image that fails
onError → a monogram tinted from a hash of the seed. The failure flag is reset
by adjusting state DURING RENDER when the URL changes, so a swapped avatar
gets a fresh attempt instead of inheriting the previous one's failure.
- The compact panel flips above the trigger when there is not enough room below.
The measurement runs inside a requestAnimationFrame (the panel is already
mounted, so it measures a real height instead of guessing) and the frame is
cancelled on unmount. An outside `pointerdown` — not click — closes it, so the
panel is gone before the press lands on whatever is underneath. Closing
restores focus to the trigger; leaving focus on a node that is about to
unmount drops it to <body>.
- The panel can never outlive its branch: switching variant or losing the data
closes it by adjusting state during render, so `aria-controls` never points at
an id that is no longer in the document.
- The "create a persona" affordance lives OUTSIDE both containers on purpose —
a full-width dashed tile under the radiogroup (a radiogroup owns radios, and a
button that answers aria-checked would be a lie), and a sibling icon button
next to the compact trigger (a listbox owns options, and a button inside the
popup is unreachable while focus stays on the combobox). It also appears
inside the empty state, where creating one IS the whole point of the panel.
- Four first-class states, not `&&` afterthoughts: loading = placeholder cards
shaped like the real ones plus an sr-only role="status"; empty (also used when
`ready` arrives with an empty array, because a control with a border and no
rows reads as broken) = an explanation plus the create affordance; error =
role="alert" with a whitespace-preserving message and an optional Retry;
ready = the grid or the combobox.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
border-primary + bg-primary/5 + ring-primary for the chosen card, bg-primary +
text-primary-foreground for the check mark, bg-accent/40 for hover,
bg-popover / text-popover-foreground for the panel, border-destructive/40 +
bg-destructive/5 for the error envelope. The monogram takes its colour from
var(--chart-1..5), indexed by a hash of the seed — swap the chart tokens and
every face re-skins with the theme. No hard-coded colours anywhere.
- Columns are CONTAINER queries (@container/personas + @xl/... + @4xl/...), not
viewport breakpoints: the picker reacts to the width it was given, so it
behaves the same in a 320px sidebar and in a full-width settings page.
- Long names and taglines use `wrap-anywhere` (overflow-wrap: anywhere) plus
`min-w-0`, NOT `break-words`: only the former lowers the element's min-content
width, which is what actually stops a 45-character persona name from widening
the whole grid. The compact trigger is the one place text truncates, and
truncation there is CSS-only — the full string stays in the DOM and in the
accessibility tree.
- A single persistent sr-only role="status" spells out the current persona,
mounted for the whole ready branch so a CHANGE is announced from an element
the screen reader is already watching. Each card's accessible name is one
sentence — name · badge · tagline · skills · why it is unavailable.
- The avatar is aria-hidden: the row already names the persona, and a second
announcement of the same name is noise. Unavailable rows use aria-disabled,
never the native `disabled` attribute, which would remove them from the tab
order together with the reason they are there to explain.
- Every transition is motion-reduce-guarded and the loading pulse stops under
prefers-reduced-motion; nothing about selection depends on an animation.
- cn() merges the consumer's className into the root, which also spreads the
remaining div props and forwards its ref.
Customization levers
- Layout: `variant` picks the interaction model, `columns` (1/2/3) sets the card
density, and the container-query thresholds are the two class strings to edit
if your cards carry more text than a tagline.
- Which sub-blocks exist: drop `showSystemHint` for a picker where the fragment
is private; omit `onCreate` and every create affordance disappears; pass
`emptyState` to replace the empty body wholesale; omit `onRetry` and the error
envelope stops offering one.
- Faces: `avatarSrc` is the whole avatar policy in one function — DiceBear
(`https://api.dicebear.com/9.x/notionists/svg?seed=`), your CDN, or a data URL.
Return undefined for the monogram; re-key the tint by changing the hash if you
want personas grouped by team colour instead of by seed.
- Density of information: `maxTags` decides how many capability chips are drawn
before "+N"; `skeletonCount` matches the loading placeholder to the roster
size you usually return.
- Wording: `label`, `placeholder`, `createLabel`, `emptyMessage`, `errorMessage`
are the entire copy deck — translate them, don't fork the component.
- Selection policy stays with you: `onValueChange` receives the id and the
persona, and nothing is written unless you write it. To pin a default,
pass `defaultValue` (uncontrolled) or drive `value` from your own store.Concepts
- Persona as a saved preset — a persona is not "an option with an icon": it bundles a voice, a job and the ground rules the next turn runs under. That is why the row carries a tagline and capability tags instead of a label, and why the panel underneath quotes the system-prompt fragment verbatim — choosing a personality should not mean choosing by haircut.
- One contract, two interaction models — the same
items+value+onValueChangerender either a radiogroup of comparable tiles or a combobox over a listbox. They are deliberately not the same widget in two sizes: a radiogroup selects as focus moves and wraps at the ends, a listbox moves an active option witharia-activedescendant, clamps at the ends and commits on Enter. Picking the wrong one gives half your users the wrong keyboard. - A seed, not a URL — storing
avatarSeedkeeps the face stable across sessions and servers, costs no network in the default render, and survives a dead CDN:avatarSrcturns the seed into an image, and an image that fails falls back to a monogram tinted from a hash of the same seed, so a broken avatar never becomes a torn icon in the middle of a card. - Unavailable with a reason — a persona your plan cannot call still appears, still takes arrow-key focus so a screen reader can read why, and still refuses to commit.
aria-disabledrather than the nativedisabledis what keeps it reachable; hiding it instead would leave the user hunting for a persona a teammate told them about. - A stale selection is surfaced, not repaired — when the committed id is no longer in the list, the picker names the missing id and waits. Auto-selecting the nearest persona would overwrite a deliberate choice with a guess, and the user would never know it happened.
- The create affordance sits outside the group — a radiogroup owns radios and a listbox owns options, so the "create a persona" button lives beside them (a dashed tile under the grid, an icon button next to the trigger) instead of pretending to be a choice that can be checked.
Prompt Enhancer
An "improve my prompt" affordance for a prompt field — a shimmering request state, a word-level diff of the rewrite, and a one-shot Accept with undo.
Tone Selector
A radio-group row of rewrite tones where every pill carries a sample phrase, and the custom tone applies only once its free-text instruction is committed.