Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Choice popover

New

Multi-select popovers for a CRM, laid out as a board of their states: ticked records pinned on top, searches with their matches in bold, and a row that creates what was typed.

choice-popover/choice-popover.tsx
Live · interactive
Dana Whitlow
Joel Marchetti
Daniela Villanueva
Ivo Bell
Noa Amari
Avery Lindholm
Oscar Villalobos
Damon Okoye
Create new Person…

Sorrel
Duograph
Farwater
Corlit
Fenwild
Inlet
Fernlow
Create new Company…

Waypost$4.5kDEAL-88
Waypost$8.2kDEAL-212
Farwater$8.2kDEAL-214
Fenwild$6.5kDEAL-215
Corlit$12.2kDEAL-219
Inlet$3.7kDEAL-221
Farwater$8.2kDEAL-230
Create new Deal…

Search state without results

Create: "Darren"

Empty state with empty items

Start typing to create

Search state with results

Show deal value in the sidebar· 19
Valuation fields on companies· 18
Create: "Valu"

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Linking several records to one thing: the people on a deal, the companies behind a request.
  • Lists long enough to need a search, short enough to scroll: a few to a few hundred records.
  • When what is already linked should stay in view: the ticked ones are pinned above a line.
  • When a missing record should be made on the spot, from what was typed, without leaving the list.
  • Records told apart by a picture and a detail: a face, a logo, a deal’s amount and stage.

When not to use

  • One answer only. Use a single-select menu or a radio group, which close on the pick.
  • Three to five fixed options. Show them as checkboxes or chips, with nothing to search.
  • Thousands of records fetched as you type: this one filters the list it holds.
  • Filters whose results should update as each box is ticked across a whole page. Use a filter bar.
  • Picking tags that are just words. A token field shows the choices where they are used.

You can add this component with the shadcn CLI or by hand:

Into components/choice-popover of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/choice-popover.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

choice-board.tsx
import { ChoicePopover, type ChoiceSlot } from './choice-popover';// One popover alone: <Choice kind="person" items={PEOPLE} onChange={save} /> from './choice'.export function ChoiceBoard({ save }: { save: (popover: ChoiceSlot, ids: string[]) => void }) {  return <ChoicePopover onChange={save} autoplay={false} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Combobox popovers that add people, companies, feedback and deals to a record. Ticked items are pinned above a separator. A search highlights its matches and offers to create what it did not find, and an empty list says to start typing. Every popover on the board is live.

  • Each popover is a search field over a multi-select listbox and a create row. A row draws what its record needs: an avatar, a company mark, a feedback status and count, or a deal’s amount, reference and progress ring.
  • Ticked records are pinned above the separator, a little closer together than the rest, but a tick never moves its row at once. The list settles when the pointer leaves, the field loses focus or the search changes, and the rows then glide to their new places in a FLIP step on the Web Animations API.
  • The rows keep their place in the page: CSS order lays them out and a search hides the ones it leaves out, so nothing is added, removed or moved in the DOM while the demo plays. A record created from a search draws its initial in HTML.
  • A search lights nothing while it has results; when it has none, it lights the create row, which names the search (Create: “Darren”). The deal popover keeps “Create new Deal…” whatever is typed. A row shows its checkbox when lit, when ticked, and on every row while searching.
  • The board takes three columns, then two, then one, by container queries, and its text size follows its width.
  • Every colour is a light-dark() token on the root, and on a Choice rendered on its own, which takes the colorScheme prop too, so it follows the page’s color-scheme. Avatars and company marks keep their colours, except one mark drawn in black, which turns white at night.
PropTypeDefaultDescription
onChange(popover: ChoiceSlot, selected: string[]) => void—Called with a popover’s name and its ticked ids whenever the reader changes them. Choice: called with its ticked ids alone.
kind'person' | 'company' | 'feedback' | 'deal'—Choice: what the records are, which names the popover (“Add Person…”) and draws the rows. Required.
itemsChoiceItem[]—Choice: the records to choose from, such as PEOPLE, COMPANIES, FEEDBACK or DEALS. Required.
defaultSelectedstring[][]Choice: the ids ticked at first.
defaultQuerystring''Choice: the search the popover starts with.
rowsnumber6.7Choice: how many rows show before the list scrolls.
createFromQuerybooleankind !== 'deal'Choice: whether the create row names the search, as in Create: “Darren”, rather than “Create new Deal…”.
refRef<ChoiceHandle>—Choice: drives the popover as a pointer and a keyboard would (light, toggle, search, create, settle, reset); the board’s demo uses it.
autoplaybooleantrueWhile on screen and untouched, a demo pointer ticks a person and a deal, searches the companies and ticks one, then types a new company’s name and creates it.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own; on Choice, for the popover.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

choice-popover.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useLayoutEffect, useRef, useState } from 'react';import { Choice, type ChoiceHandle } from './choice';import { COMPANIES, DEALS, FEEDBACK, PEOPLE } from './data';import { DemoPointer, runDemo } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './choice-popover.module.css';/* * Choice popover: multi-select popovers for a CRM, laid out as a board of their states, every * one of them live (choice.tsx is the popover itself). * * - On top, choosing: people, companies and deals, the ticked ones pinned above a separator. *   Under them, the search states: no results (the create row names the search), an empty list *   (start typing to create) and results with the matches in bold. * - The board takes the columns its width allows: three, then two, then one on a phone, each *   popover a set width in em of the text, which follows the width too. * - While on screen and untouched, a demo pointer ticks a person and a deal, searches the *   companies and ticks one, then names a new company and creates it; the board then starts over. *   On a narrow board it does the same in the popovers it shows. * - Light or dark: every colour of the board and the popovers is a light-dark() token in the CSS,
choice.tsx
'use client';import {  useId,  useImperativeHandle,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type ReactNode,  type Ref,} from 'react';import { NOUNS, createItem, type ChoiceItem, type Kind } from './data';import { Avatar, CompanyMark, PlusCircle, Ring, StatusIcon } from './visuals';import styles from './choice-popover.module.css';/* * One choice popover: a search field over a list of records to tick, and a row that creates one. * * - The ticked records are pinned at the top, above a separator. A tick does not move its row at *   once, which would pull it from under the pointer: the list settles when the pointer leaves, *   when the field loses focus or when the search changes, and the rows glide to their places. * - The rows keep their order in the page and are placed by CSS `order`, and a search hides the
choice-popover.module.css
/* Choice popover (choice-popover.tsx, choice.tsx): a stage with a board of popovers, three   columns wide, then two, then one on a phone. Sizes are in em of the stage's text: 14px wherever   the columns fit, a little less on the way down. A popover's geometry is in em of 0.93 of it,   and its words are a touch smaller again (--words).   The stage is one layout boundary of a set height, so what the demo changes (rows lit, ticked,   filtered or settling) lays out the stage alone; the rows glide by their transform and the   pointer moves on a small layer of its own.   Colours are the tokens below, light, then light-dark(light, dark) pairs: the component takes the   color-scheme it inherits from the page (light unless the page says otherwise), or the one its   colorScheme prop sets (data-color-scheme). The pairs sit in @supports, which a build that   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light   colours stay. The avatars and the company marks keep their own colours. A popover on its own   (Choice, outside the board) carries the tokens itself. */.root,.popover:not(.root *) {  /* The stage and its edge; a popover, its border and its lift; a lit row (under the pointer or     the keys). */  --stage: #fafafa;  --stage-edge: rgb(0 0 0 / 0.06);  --surface: #fff;  --border: #e3e3e3;
data.ts
/* * What the popovers choose from: people, companies, feedback and deals of a small CRM. Every * record has an id and the label a search matches; the rest is what its row draws. */export type AvatarLook = {  /** The circle behind. */  bg: string;  skin: string;  hair: string;  /** The hair's cut. */  cut: 'short' | 'long' | 'bun' | 'curly' | 'crop';  shirt: string;};export type MarkName =  'sorrel' | 'duograph' | 'farwater' | 'corlit' | 'fenwild' | 'inlet' | 'fernlow' | 'waypost';export type FeedbackStatus = 'synced' | 'progress' | 'open';export type ChoiceItem =  | { kind: 'person'; id: string; label: string; avatar: AvatarLook | null }  | { kind: 'company'; id: string; label: string; mark: MarkName | null }  | { kind: 'feedback'; id: string; label: string; status: FeedbackStatus; count: number }
visuals.tsx
import type { CSSProperties, ReactNode } from 'react';import type { AvatarLook, FeedbackStatus, MarkName } from './data';/* * The rows' small pictures: illustrated avatars, company marks, the status of feedback, the * progress ring of a deal and the create row's plus. All decorative: every row says in words * what they show. * * The avatars and the marks keep their colours in both schemes, but for the one mark drawn in * black alone, which takes --mark-ink (white at night). The ring's colours are the stage's * tokens (choice-popover.module.css). *//** A person, drawn: a coloured disc (cut round by its class), the shoulders, the face, the hair. */export function Avatar({ look, className }: { look: AvatarLook; className?: string }) {  const hair: Record<AvatarLook['cut'], ReactNode> = {    short: (      <path d="M6.6 9.6c-.3-3 1.6-5 4.4-5s4.8 1.9 4.5 5c-.9-1.4-2.6-2.2-4.5-2.3-1.9.1-3.5.9-4.4 2.3z" />    ),    crop: (      <path d="M7 8.6c.1-2.6 1.8-3.9 4-3.9s3.9 1.3 4 3.9c-1.1-.9-2.4-1.3-4-1.3s-2.9.4-4 1.3z" />    ),    long: (      <path d="M6.2 15.2c-.9-1.9-.8-4.7-.3-6.6.7-2.6 2.6-3.9 5.1-3.9s4.4 1.3 5.1 3.9c.5 1.9.6 4.7-.3 6.6-.4-1.6-.8-3.8-.9-5.5-1.1-.9-2.5-1.5-3.9-1.5S8.2 8.8 7.1 9.7c-.1 1.7-.5 3.9-.9 5.5z" />
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a target, press, calling the board's own handlers as a hand would. * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once: * its promise rejects, which ends the script right there, and the pointer fades out. * * The pointer moves by animations the compositor plays, so the glides cost no layout, and it is * an HTML box around a still drawing: a change of style inside an SVG lays the SVG out again. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;  /** Fades the pointer out where it is. */
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Each search field is a combobox that owns a multi-select listbox. Options carry aria-selected for their tick, and aria-posinset and aria-setsize for the order they show in, and aria-activedescendant follows the lit row.
  • ↑ and ↓ light the rows in order, with the create row last. Enter ticks or creates, and Escape clears the search. The empty state’s hint is marked unavailable.
  • Every tick, untick and creation is announced (“Ivo Bell added”, “Created Darren and added it”). Avatars, marks, status icons and rings are decorative; the words are in the row.
  • Under reduced motion the rows settle without gliding and the demo does not play.