Call me on 415 555 0199 or write to sam@studio.io before Friday.
Query
React + Tailwind
import { useState } from 'react';
const css = `
@keyframes field-query-pill { from { background-color: light-dark(#cbced3, #393c41); box-shadow: 0 0 0 3px light-dark(#cbced3, #393c41); } }
@keyframes field-query-menu { from { opacity: 0; transform: translateY(-4px) scale(0.98); } }
.field-query { position: relative; width: 256px; }
.field-query__box {
position: relative;
display: flex;
align-items: center;
height: 42px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-query__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-query__icon { position: absolute; left: 11px; width: 16px; height: 16px; color: light-dark(#5f626a, #a7abb2); }
.field-query__input, .field-query__mirror { padding: 0 10px 0 34px; font-family: inherit; font-size: 14px; font-kerning: none; letter-spacing: 0; }
.field-query__input { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-query__input::placeholder { color: light-dark(#5f626a, #a7abb2); }
.field-query__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; }
/* Pills are drawn with a background and a spread shadow, never padding, so the text stays aligned with the caret. */
.field-query__pill { border-radius: 4px; background: light-dark(#ebedf0, #232529); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529); animation: field-query-pill 0.35s ease-out; }
.field-query__key { color: light-dark(#5f626a, #a7abb2); }
.field-query__val { color: light-dark(#373a40, #f1f2f3); }
.field-query__not { color: light-dark(#5f626a, #a7abb2); text-decoration: line-through; text-decoration-color: light-dark(#6d7179, #7d8087); }
.field-query__phrase { color: light-dark(#373a40, #f1f2f3); font-style: normal; text-decoration: underline dotted light-dark(#6d7179, #7d8087); text-underline-offset: 3px; }
.field-query__menu {
position: absolute;
left: 0;
right: 0;
top: 48px;
z-index: 2;
margin: 0;
padding: 4px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 24px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
list-style: none;
animation: field-query-menu 0.18s ease-out;
}
.field-query__opt { display: flex; justify-content: space-between; padding: 6px 8px; border-radius: 6px; color: light-dark(#42454c, #e5e6e9); font-size: 12px; cursor: pointer; }
.field-query__opt span { color: light-dark(#5f626a, #a7abb2); }
.field-query__opt[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) { .field-query__pill, .field-query__menu { animation: none; } }
`;
const KEYS: Record<string, { hint: string; values: string[] }> = {
'tag:': { hint: 'topic', values: ['motion', 'loader', 'button', '3d', 'scroll'] },
'by:': { hint: 'creator', values: ['raza', 'studio', 'anon'] },
'is:': { hint: 'state', values: ['saved', 'new', 'pick'] },
'type:': { hint: 'kind', values: ['clip', 'component', 'shader'] },
};
const KEY_RE = /^(-?)(tag|by|is|type):(\S*)$/;
function Token({ text, start }: { text: string; start: number }) {
const m = KEY_RE.exec(text);
if (m) {
return (
<span key={`${start}${m[2]}`} className="field-query__pill">
<span className="field-query__key">
{m[1]}
{m[2]}:
</span>
<span className="field-query__val">{m[3]}</span>
</span>
);
}
if (/^-\S+/.test(text)) return <span className="field-query__not">{text}</span>;
if (/^"/.test(text)) return <span className="field-query__phrase">{text}</span>;
return <>{text}</>;
}
export function QueryInput() {
const [value, setValue] = useState('tag:motion -gradient by:');
const [active, setActive] = useState(0);
// Suggestions show only while the field is focused, so the field rests minimal.
const [focused, setFocused] = useState(false);
// Suggest keys while typing a bare word, and values once a key is typed.
const last = value.split(/\s/).pop() ?? '';
const km = KEY_RE.exec(last);
const options: { insert: string; label: string; hint: string }[] = km
? KEYS[`${km[2]}:`].values.filter((v) => v.startsWith(km[3]) && v !== km[3]).map((v) => ({ insert: `${km[1]}${km[2]}:${v} `, label: v, hint: km[2] }))
: /^-?[a-z]+$/.test(last)
? Object.keys(KEYS)
.filter((k) => k.startsWith(last.replace(/^-/, '')))
.map((k) => ({ insert: `${last.startsWith('-') ? '-' : ''}${k}`, label: k, hint: KEYS[k].hint }))
: [];
const open = focused && options.length > 0;
const pick = (o: (typeof options)[number]) => {
setValue(value.slice(0, value.length - last.length) + o.insert);
setActive(0);
};
let at = 0;
return (
<>
<style>
{css}
</style>
<div className="field-query">
<div className="field-query__box">
<svg className="field-query__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-4-4" />
</svg>
<span className="field-query__mirror" aria-hidden="true">
<span>
{value.split(/(\s+)/).map((t, i) => {
const start = at;
at += t.length;
return <Token key={i} text={t} start={start} />;
})}
</span>
</span>
<input
className="field-query__input"
value={value}
placeholder="Search, or try tag: by: is:"
aria-label="Search"
role="combobox"
aria-expanded={open}
aria-controls="field-query-menu"
aria-autocomplete="list"
aria-activedescendant={open ? `field-query-opt-${active % options.length}` : undefined}
autoComplete="off"
spellCheck={false}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(e) => {
setValue(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (!open) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length);
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pick(options[active % options.length]);
}
}}
/>
</div>
{open && (
<ul id="field-query-menu" className="field-query__menu" role="listbox" aria-label="Suggestions">
{options.map((o, i) => (
<li
key={o.label}
id={`field-query-opt-${i}`}
className="field-query__opt"
role="option"
aria-selected={i === active % options.length}
onPointerDown={(e) => {
e.preventDefault();
pick(o);
}}
>
{o.label}
<span>{o.hint}</span>
</li>
))}
</ul>
)}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldQuery(el) on the mounted element instead.
HTML
<div class="field-query">
<div class="field-query__box">
<svg class="field-query__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-4-4"></path>
</svg>
<span class="field-query__mirror" aria-hidden="true"><span><span class="field-query__pill"><span class="field-query__key">tag:</span><span class="field-query__val">motion</span></span> <span class="field-query__not">-gradient</span> <span class="field-query__pill"><span class="field-query__key">by:</span><span class="field-query__val"></span></span></span></span>
<input class="field-query__input" placeholder="Search, or try tag: by: is:" aria-label="Search" role="combobox" aria-expanded="false" aria-controls="field-query-menu" aria-autocomplete="list" autoComplete="off" spellCheck="false" value="tag:motion -gradient by:"/>
</div>
</div>CSS
@keyframes field-query-pill { from { background-color: light-dark(#cbced3, #393c41); box-shadow: 0 0 0 3px light-dark(#cbced3, #393c41); } }
@keyframes field-query-menu { from { opacity: 0; transform: translateY(-4px) scale(0.98); } }
.field-query { position: relative; width: 256px; }
.field-query__box {
position: relative;
display: flex;
align-items: center;
height: 42px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-query__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-query__icon { position: absolute; left: 11px; width: 16px; height: 16px; color: light-dark(#5f626a, #a7abb2); }
.field-query__input, .field-query__mirror { padding: 0 10px 0 34px; font-family: inherit; font-size: 14px; font-kerning: none; letter-spacing: 0; }
.field-query__input { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-query__input::placeholder { color: light-dark(#5f626a, #a7abb2); }
.field-query__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; }
/* Pills are drawn with a background and a spread shadow, never padding, so the text stays aligned with the caret. */
.field-query__pill { border-radius: 4px; background: light-dark(#ebedf0, #232529); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529); animation: field-query-pill 0.35s ease-out; }
.field-query__key { color: light-dark(#5f626a, #a7abb2); }
.field-query__val { color: light-dark(#373a40, #f1f2f3); }
.field-query__not { color: light-dark(#5f626a, #a7abb2); text-decoration: line-through; text-decoration-color: light-dark(#6d7179, #7d8087); }
.field-query__phrase { color: light-dark(#373a40, #f1f2f3); font-style: normal; text-decoration: underline dotted light-dark(#6d7179, #7d8087); text-underline-offset: 3px; }
.field-query__menu {
position: absolute;
left: 0;
right: 0;
top: 48px;
z-index: 2;
margin: 0;
padding: 4px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 24px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
list-style: none;
animation: field-query-menu 0.18s ease-out;
}
.field-query__opt { display: flex; justify-content: space-between; padding: 6px 8px; border-radius: 6px; color: light-dark(#42454c, #e5e6e9); font-size: 12px; cursor: pointer; }
.field-query__opt span { color: light-dark(#5f626a, #a7abb2); }
.field-query__opt[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) { .field-query__pill, .field-query__menu { animation: none; } }JavaScript
function initFieldQuery(root) {
var input = root.querySelector('.field-query__input');
var mirror = root.querySelector('.field-query__mirror');
if (!input || !mirror) return;
var KEYS = {
'tag:': { hint: 'topic', values: ['motion', 'loader', 'button', '3d', 'scroll'] },
'by:': { hint: 'creator', values: ['raza', 'studio', 'anon'] },
'is:': { hint: 'state', values: ['saved', 'new', 'pick'] },
'type:': { hint: 'kind', values: ['clip', 'component', 'shader'] },
};
var KEY_RE = /^(-?)(tag|by|is|type):(\S*)$/;
var active = 0;
var options = [];
var menu = null;
var known = {};
// Suggestions show only while the field is focused, so the field rests minimal.
var focused = false;
function token(text, start) {
var m = KEY_RE.exec(text);
if (m) {
var pill = document.createElement('span');
pill.className = 'field-query__pill';
// Only a pill that just appeared flashes.
if (known[start + m[2]]) pill.style.animation = 'none';
known[start + m[2]] = true;
pill.innerHTML = '<span class="field-query__key"></span><span class="field-query__val"></span>';
pill.firstChild.textContent = m[1] + m[2] + ':';
pill.lastChild.textContent = m[3];
return pill;
}
if (/^-\S+/.test(text) || /^"/.test(text)) {
var s = document.createElement('span');
s.className = /^"/.test(text) ? 'field-query__phrase' : 'field-query__not';
s.textContent = text;
return s;
}
return document.createTextNode(text);
}
function render() {
var value = input.value;
// One inline run: whitespace-only text would be dropped inside the flex mirror.
var run = document.createElement('span');
var at = 0;
var seen = {};
value.split(/(\s+)/).forEach(function (t) {
var m = KEY_RE.exec(t);
if (m) seen[at + m[2]] = true;
run.appendChild(token(t, at));
at += t.length;
});
Object.keys(known).forEach(function (k) { if (!seen[k]) delete known[k]; });
mirror.textContent = '';
mirror.appendChild(run);
suggest();
}
function suggest() {
var value = input.value;
var last = value.split(/\s/).pop() || '';
var km = KEY_RE.exec(last);
options = km
? KEYS[km[2] + ':'].values.filter(function (v) { return v.indexOf(km[3]) === 0 && v !== km[3]; }).map(function (v) {
return { insert: km[1] + km[2] + ':' + v + ' ', label: v, hint: km[2] };
})
: /^-?[a-z]+$/.test(last)
? Object.keys(KEYS).filter(function (k) { return k.indexOf(last.replace(/^-/, '')) === 0; }).map(function (k) {
return { insert: (last.charAt(0) === '-' ? '-' : '') + k, label: k, hint: KEYS[k].hint };
})
: [];
if (menu) menu.remove();
menu = null;
if (!focused) options = [];
input.setAttribute('aria-expanded', String(options.length > 0));
if (!options.length) {
input.removeAttribute('aria-activedescendant');
return;
}
menu = document.createElement('ul');
menu.id = 'field-query-menu';
menu.className = 'field-query__menu';
menu.setAttribute('role', 'listbox');
menu.setAttribute('aria-label', 'Suggestions');
options.forEach(function (o, i) {
var li = document.createElement('li');
li.id = 'field-query-opt-' + i;
li.className = 'field-query__opt';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', String(i === active % options.length));
li.textContent = o.label;
var hint = document.createElement('span');
hint.textContent = o.hint;
li.appendChild(hint);
li.addEventListener('pointerdown', function (e) {
e.preventDefault();
pick(o);
});
menu.appendChild(li);
});
input.setAttribute('aria-activedescendant', 'field-query-opt-' + (active % options.length));
root.appendChild(menu);
}
function pick(o) {
var value = input.value;
var last = value.split(/\s/).pop() || '';
input.value = value.slice(0, value.length - last.length) + o.insert;
active = 0;
render();
}
function onInput() {
active = 0;
render();
}
function key(e) {
if (!options.length) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
active = (active + (e.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length;
suggest();
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pick(options[active % options.length]);
}
}
function onFocus() {
focused = true;
suggest();
}
function onBlur() {
focused = false;
suggest();
}
input.addEventListener('input', onInput);
input.addEventListener('keydown', key);
input.addEventListener('focus', onFocus);
input.addEventListener('blur', onBlur);
render();
return function () {
if (menu) menu.remove();
input.removeEventListener('input', onInput);
input.removeEventListener('keydown', key);
input.removeEventListener('focus', onFocus);
input.removeEventListener('blur', onBlur);
};
}
document.querySelectorAll('.field-query').forEach(initFieldQuery);Goal: Create a "Query" input.
Visual behavior: A search field that understands filters: tag:, by:, is: and type: tokens become pills as you type them, -words are struck through, quoted phrases are marked, and a dropdown suggests keys and then their values.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- The value is split on whitespace (kept, inside one inline run, because whitespace-only text is dropped in a flex container); key tokens render as pills drawn with a background and a spread box-shadow, never padding, so text widths never change.
- The last token drives suggestions: a bare word suggests matching keys, a key suggests its values; arrows move through them, Enter or Tab inserts one.
- A newly recognised pill flashes once on a keyframe.
Accessibility: The input is a combobox (aria-expanded, aria-controls, aria-activedescendant) over a listbox of options; it has an aria-label. Under prefers-reduced-motion pills and menu appear without animation.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Query" input.
Visual behavior: A search field that understands filters: tag:, by:, is: and type: tokens become pills as you type them, -words are struck through, quoted phrases are marked, and a dropdown suggests keys and then their values.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- The value is split on whitespace (kept, inside one inline run, because whitespace-only text is dropped in a flex container); key tokens render as pills drawn with a background and a spread box-shadow, never padding, so text widths never change.
- The last token drives suggestions: a bare word suggests matching keys, a key suggests its values; arrows move through them, Enter or Tab inserts one.
- A newly recognised pill flashes once on a keyframe.
Accessibility: The input is a combobox (aria-expanded, aria-controls, aria-activedescendant) over a listbox of options; it has an aria-label. Under prefers-reduced-motion pills and menu appear without animation.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A search field that understands filters: tag:, by:, is: and type: tokens become pills as you type them, -words are struck through, quoted phrases are marked, and a dropdown suggests keys and then their values.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- focus, state change
- Techniques
- keyframes, state machine, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The input is a combobox (aria-expanded, aria-controls, aria-activedescendant) over a listbox of options; it has an aria-label. Under prefers-reduced-motion pills and menu appear without animation.