Forms
Compound-value picker
One grouped select whose option value carries both the kind of thing and its id, so someone names the thing they already had in mind instead of first answering a question about categories that only the schema cares about.
Reach for it when building
- assigning a record to a person or a group
- picking an owner that may be a user or a team
- choosing a payee that may be a person or a company
- attaching an appointment to a family member or a pet
- selecting a destination that may be a folder or a tag
- any field whose target can be one of several types
- select
- optgroup
- polymorphic
- forms
- dependent-fields
- keyboard
<div class="cvp-demo">
<label class="cvp-field" for="cvp-select">
<span>Who is this appointment for?</span>
<select id="cvp-select">
<optgroup label="Family">
<option value="person:1">Ada</option>
<option value="person:2">Marcus</option>
<option value="person:3">Wren</option>
</optgroup>
<optgroup label="Pets">
<option value="pet:7">Biscuit</option>
<option value="pet:9">Juniper</option>
</optgroup>
</select>
</label>
<p class="cvp-out" id="cvp-out" role="status"></p>
<div class="cvp-versus">
<h3 class="cvp-h">The version this replaces</h3>
<div class="cvp-pair">
<label class="cvp-field cvp-muted">
<span>Kind</span>
<select disabled>
<option>Person</option>
</select>
</label>
<label class="cvp-field cvp-muted">
<span>Then who</span>
<select disabled>
<option>Ada</option>
</select>
</label>
</div>
<p class="cvp-note">Two controls, and the first one asks a question nobody arrived with.</p>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.cvp-demo { display: flex; flex-direction: column; gap: 1rem; max-width: 460px; }
.cvp-field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9375rem; color: var(--dim); }
.cvp-field select {
font: inherit;
min-height: 44px;
padding: 0.45rem 0.6rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
width: 100%;
}
.cvp-out { margin: 0; font-size: 0.9375rem; color: var(--ink); font-weight: 600; min-height: 1.6em; }
.cvp-versus {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: var(--surface-2);
}
.cvp-h { margin: 0; font-size: 0.9375rem; color: var(--dim); font-weight: 600; }
.cvp-pair { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.cvp-pair .cvp-field { flex: 1 1 10rem; }
/* Shown only to be compared against, so it is visibly inert rather than
pretending to be a working control. */
.cvp-muted select { color: var(--dim); background: var(--surface); cursor: not-allowed; }
.cvp-note { margin: 0; font-size: 0.9375rem; color: var(--dim); font-style: italic; }const KINDS = { person: 'family member', pet: 'pet' };
const select = document.getElementById('cvp-select');
const out = document.getElementById('cvp-out');
function render() {
/* One control, two fields of data. The separator has to be one the ids can
never contain, and the split is on the first occurrence only. */
const raw = select.value;
const boundary = raw.indexOf(':');
const kind = raw.slice(0, boundary);
const id = raw.slice(boundary + 1);
const label = select.options[select.selectedIndex].textContent;
out.textContent =
label + ' → stored as kind “' + kind + '” (' + KINDS[kind] + '), id ' + id + '.';
}
select.addEventListener('change', render);
render();Paste this into an agent to rebuild the pattern from scratch.
Build one select whose options span several kinds of thing, grouped by kind, where the option's value encodes both the kind and the id.
Reach for this wherever a field's target is polymorphic: an owner that may be a user or a team, a payee that may be a person or a company, an appointment for a family member or a pet. Walk away when one kind has hundreds of members — a select stops being usable long before that, and the answer is a combobox with search rather than a second select.
The rule that makes it work: **join the kind and the id into the option value, and split on change.** The alternative everyone builds — a kind select that filters a second, dependent select — forces people to answer a question about your schema before they can name the thing they already had in mind. Nobody thinks "I need a person, and then that person is Ada"; they think "Ada".
Pick a separator the ids cannot contain, and split on its first occurrence rather than splitting into an array. A colon is fine for numeric ids and wrong for ids that may themselves contain one; when in doubt, encode the pair as two data attributes on the option and read those instead.
Group with real `optgroup` elements. They give the browser's own picker its section headers, they are announced as groups, and on mobile they survive into the native wheel — none of which a styled div list gets for free.
**Filter out empty groups rather than rendering them empty.** A "Pets" heading with nothing under it reads as a loading failure, and a group that is empty for everyone is a schema detail nobody needs to see.
Keep the stored shape explicit in the readout during development, or you will eventually ship a form that posts `"person:3"` to an endpoint expecting an integer. Splitting is the last thing that happens before the value leaves the form.
The control reaches 44px through padding, the label stays at body size, and every colour comes from theme custom properties.