Forms
Parsed query chips
A search box that echoes back how it read the text: every recognised qualifier becomes its own removable chip, exclusions say "not" in words rather than relying on a leading dash, and anything unrecognised is shown as plain text rather than silently dropped.
Reach for it when building
- a photo or media library search
- an issue tracker with query syntax
- a log or transcript search
- a file browser with filters
- an email client search box
- any search that quietly supports key:value terms
- search
- query-syntax
- chips
- facets
- parsing
- aria-live
<div class="pqc-demo">
<label class="pqc-field">
<span>Search photos</span>
<input type="search" id="pqc-input" value="camera:fuji before:2024 -screenshot beach cliffs" autocomplete="off" spellcheck="false" />
</label>
<ul class="pqc-chips" id="pqc-chips" aria-label="Terms in this search"></ul>
<p class="pqc-note" id="pqc-note" role="status"></p>
<p class="pqc-hint">Recognised keys: camera, before, after, album, lens. Prefix a word with a dash to exclude it.</p>
</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.
.pqc-demo { display: flex; flex-direction: column; gap: 0.75rem; max-width: 560px; }
.pqc-field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9375rem; color: var(--dim); }
.pqc-field input {
font: inherit;
font-family: var(--mono);
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%;
}
.pqc-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
/* The chip reaches the 34px floor through padding, so the label can stay at
body size instead of shrinking to fit a smaller pill. */
.pqc-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 34px;
padding: 0.25rem 0.3rem 0.25rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--ink);
font-size: 0.9375rem;
}
.pqc-chip[data-kind='qualifier'] { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 14%, var(--surface)); }
/* An exclusion never relies on the dash alone: it says "not", and it takes a
different border so the difference survives a greyscale print. */
.pqc-chip[data-kind='exclusion'] { border-color: var(--signal); border-style: dashed; color: var(--signal); }
.pqc-key { font-weight: 600; }
.pqc-drop {
font: inherit;
line-height: 1;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
}
.pqc-drop:hover { background: color-mix(in oklab, var(--ink) 14%, transparent); }
.pqc-note { margin: 0; font-size: 0.9375rem; color: var(--dim); min-height: 1.6em; }
.pqc-hint { margin: 0; font-size: 0.9375rem; color: var(--dim); font-style: italic; }const QUALIFIERS = ['camera', 'before', 'after', 'album', 'lens'];
const input = document.getElementById('pqc-input');
const chips = document.getElementById('pqc-chips');
const note = document.getElementById('pqc-note');
function parse(text) {
return text
.split(/\s+/)
.filter(Boolean)
.map((raw) => {
if (raw.startsWith('-') && raw.length > 1) {
return { kind: 'exclusion', raw, word: raw.slice(1) };
}
const split = raw.indexOf(':');
const key = split > 0 ? raw.slice(0, split) : '';
if (QUALIFIERS.includes(key)) {
return { kind: 'qualifier', raw, key, value: raw.slice(split + 1) };
}
return { kind: 'text', raw, word: raw };
});
}
function render() {
const terms = parse(input.value);
chips.innerHTML = '';
terms.forEach((term, index) => {
const chip = document.createElement('li');
chip.className = 'pqc-chip';
chip.dataset.kind = term.kind;
const body = document.createElement('span');
if (term.kind === 'qualifier') {
const key = document.createElement('span');
key.className = 'pqc-key';
key.textContent = term.key;
body.append(key, document.createTextNode(' is ' + term.value));
} else if (term.kind === 'exclusion') {
const key = document.createElement('span');
key.className = 'pqc-key';
key.textContent = 'not';
body.append(key, document.createTextNode(' ' + term.word));
} else {
body.textContent = term.word;
}
/* Every chip removes its own term. Rendering inert badges above a separate
row of controls is what forces people back into the raw string. */
const drop = document.createElement('button');
drop.type = 'button';
drop.className = 'pqc-drop';
drop.innerHTML = '×';
drop.setAttribute('aria-label', 'Remove ' + term.raw);
drop.addEventListener('click', () => {
input.value = terms
.filter((_, other) => other !== index)
.map((item) => item.raw)
.join(' ');
render();
input.focus();
});
chip.append(body, drop);
chips.append(chip);
});
const qualifiers = terms.filter((term) => term.kind === 'qualifier').length;
const exclusions = terms.filter((term) => term.kind === 'exclusion').length;
const free = terms.length - qualifiers - exclusions;
note.textContent =
qualifiers + ' recognised, ' + exclusions + ' excluded, ' + free + ' searched as plain text.';
}
input.addEventListener('input', render);
render();Paste this into an agent to rebuild the pattern from scratch.
Build a search box that shows its work. As someone types, parse the text and render one chip per term: recognised `key:value` qualifiers, exclusions, and leftover words that will be searched as plain text.
Reach for this the moment a search box quietly supports syntax. People discover `author:` or `before:` from a tooltip and then have no idea whether the thing they typed was understood, whether a typo made it a literal string, or which half of the query is narrowing the results. The chips answer all three without a help page. Walk away from a search box with no syntax behind it, where a row of chips restating the words is noise.
The rule that makes it work: **every chip removes its own term.** The version everyone builds first renders inert badges above a separate row of facet controls, which means dropping one qualifier still sends people back to hand-editing the raw string — and the chips have become decoration. Rebuild the query text from the surviving terms and put focus back in the input.
An exclusion is never signalled by the leading dash alone. Write "not screenshot" in words, and give the chip a second difference that is not colour — a dashed border does it. A minus sign in a pill is the single most misread thing in a query UI, and colour alone fails for anyone who cannot see it.
Show unrecognised words as ordinary text chips rather than hiding them. A misspelt `camrea:fuji` becoming a plain-text chip is how someone finds the typo; silently dropping it is how they conclude the search is broken.
Let the chip reach a 34px minimum through padding, not by shrinking the label. Chips are where small type creeps into an interface, and a query term is something people actually read.
Keep the input the source of truth. Parse it on every keystroke and render from the parse — never hold a parallel array of chips that can drift out of step with the text someone can still edit directly.
Report the composition in a `role="status"` line: how many terms were recognised, how many exclude, how many are plain text. Every colour comes from theme custom properties, and both chip kinds have to stay distinguishable in both themes.