Forms and inputsSearch input
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callSearch input
Search with scoped filters, recent queries and an agent answer. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add search-inputReact
npx designme add search-input --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Search input is a search box that opens to show recent searches, matching results, and an answer from the agent above them. Typing @ lists sources such as Figma or Notion, and picking one narrows the search to that source with a removable chip. It lets a person find a file directly or ask a question and get a written answer in the same box.
- When people search across several connected tools and need to narrow to one
- When a question typed into search should get an agent answer as well as matching files
- When the empty box should offer recent searches to pick up where someone left off
- When nothing matches and Enter should hand the query to the agent
- While the box is empty it lists
recentsearches, and picking one fills the box. - Typing @ lists your
sources; picking one sets thescopechip, and Backspace in an empty box removes it. - Results show their source, and the matched word in each label is highlighted.
- The
answerstring appears above the results, and each new word fades in as you feed it more text. - By default an item matches the whole search or any word longer than two letters, and
filterreplaces that rule. - Enter picks the highlighted row through
onSelect, or callsonSubmitwith the search and scope when no row is highlighted.
- The input is a
comboboxwitharia-expanded,aria-controlsandaria-autocomplete="list", and the results are alistboxofoptionrows. - Arrow Up and Arrow Down move the highlight, which is announced through
aria-activedescendantandaria-selected. - Enter picks or submits, Escape closes the list and keeps the focus in the box, and a second Escape clears it.
- The list's height animation is skipped when the user prefers reduced motion.
Usage
Import it and drive it from your own state.
Import
import { SearchInput } from '@/components/designme/search-input/SearchInput';Example
The demo above as a React component, 47 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { SearchInput, type SearchInputItem, type SearchInputSource } from './SearchInput';
import { useStory } from '../lib/useScript';
const ITEMS: SearchInputItem[] = [
{ icon: 'figma', label: 'Pricing v4', source: 'Figma' }, { icon: 'figma', label: 'Pricing mobile', source: 'Figma' },
{ icon: 'file-text', label: 'Pricing research notes', source: 'Notion' }, { icon: 'code', label: 'PricingCard.tsx', source: 'GitHub' },
{ icon: 'figma', label: 'Checkout flow', source: 'Figma' }, { icon: 'file-text', label: 'Brand voice guide', source: 'Notion' },
{ icon: 'code', label: 'tokens.css', source: 'GitHub' }
];
const SOURCES: SearchInputSource[] = [{ key: 'figma', label: 'Figma', icon: 'figma' }, { key: 'notion', label: 'Notion', icon: 'file-text' }, { key: 'github', label: 'GitHub', icon: 'code' }];
const RECENT = ['pricing contrast', 'checkout copy', 'tokens'];
const ANSWER = 'Pricing changed on Sep 18: annual plans first, one CTA per card. Trial to paid is up 8.1 points since.'.split(' ');
/* ---------- Search input: the box lifts, then someone asks a question and the agent answers above the results ---------- */
export default function SearchInputDemo() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
const [answer, setAnswer] = useState('');
// the preview types a question on its own; clicking, focusing or typing in it takes over, and what it had typed goes
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async ({ wait, taken }) => {
await wait(900); setOpen(true);
await wait(600);
let typed = '', typing = true;
taken(() => { if (typing) setValue(''); });
for (const ch of 'why did pricing change?') { typed += ch; setValue(typed); await wait(45); }
typing = false;
});
// a question gets an answer, a word every 45ms, which keeps going while the question is still being typed
const v = value.trim(), asking = v.split(' ').length > 2 || v.endsWith('?');
useEffect(() => {
if (!asking) { setAnswer(''); return; }
let n = 0;
const id = setInterval(() => { n++; setAnswer(ANSWER.slice(0, n).join(' ')); if (n >= ANSWER.length) clearInterval(id); }, 45);
return () => clearInterval(id);
}, [asking]);
return (
<div ref={ref} style={{ width: '100%' }}>
<SearchInput items={ITEMS} sources={SOURCES} recent={RECENT} value={value} onValueChange={setValue} open={open} onOpenChange={setOpen}
answer={asking ? answer : undefined} />
</div>
);
}
Props
Everything SearchInput takes.
| Prop | Type | What it does |
|---|---|---|
itemsrequired | SearchInputItem[] | |
sources | SearchInputSource[] | Typing @ lists these, and picking one adds it as a filter chip |
recent | string[] | Earlier searches, shown while the box is empty |
value | string | What is typed. Leave it out to let the box hold its own |
defaultValue | string | |
onValueChange | (value: string) => void | |
scope | string | null | The source filter, by label, or null. Backspace in an empty box removes it |
defaultScope | string | null | |
onScopeChange | (scope: string | null) => void | |
open | boolean | Whether the box is lifted with its results open. Focus opens it and leaving closes it when you leave it out |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
answer | string | An answer from the agent, shown above the results. Feed it as it grows and each new word fades in |
filter | (item: SearchInputItem, search: string) => boolean | Which items match the search. By default an item matches the whole search or any word longer than two letters |
onSelect | (item: SearchInputItem) => void | Called when a result is picked, by click or Enter |
onSubmit | (search: string, scope: string | null) => void | Called by Enter when no row is highlighted, with the search and the source filter |
placeholder | string | |
shortcut | string | The key hint at the end of the empty box |
emptyLabel | string | What the list says when nothing matches |
className | string |
Code
The full source, as the CLI installs it.
SearchInput.tsxReact · 191 lines
'use client';
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Fold } from '../lib/motion';
import '../designme.css';
import './search-input.css';
/** Something the box can find, like a Figma frame or a doc */
export interface SearchInputItem {
icon: IconName;
/** Its name. The part that matches the search is marked */
label: string;
/** Where it lives, like "Figma". A source filter keeps only the items whose source is its label */
source: string;
}
/** A source you can narrow the search to by typing @ and its key, like @figma */
export interface SearchInputSource {
key: string;
/** Its name, like "Figma". It becomes the filter chip */
label: string;
icon: IconName;
}
export interface SearchInputProps {
items: SearchInputItem[];
/** Typing @ lists these, and picking one adds it as a filter chip */
sources?: SearchInputSource[];
/** Earlier searches, shown while the box is empty */
recent?: string[];
/** What is typed. Leave it out to let the box hold its own */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** The source filter, by label, or null. Backspace in an empty box removes it */
scope?: string | null;
defaultScope?: string | null;
onScopeChange?: (scope: string | null) => void;
/** Whether the box is lifted with its results open. Focus opens it and leaving closes it when you leave it out */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** An answer from the agent, shown above the results. Feed it as it grows and each new word fades in */
answer?: string;
/** Which items match the search. By default an item matches the whole search or any word longer than two letters */
filter?: (item: SearchInputItem, search: string) => boolean;
/** Called when a result is picked, by click or Enter */
onSelect?: (item: SearchInputItem) => void;
/** Called by Enter when no row is highlighted, with the search and the source filter */
onSubmit?: (search: string, scope: string | null) => void;
placeholder?: string;
/** The key hint at the end of the empty box */
shortcut?: string;
/** What the list says when nothing matches */
emptyLabel?: string;
className?: string;
}
// words too common to match on, as on designme.dev
const STOP = ['why', 'did', 'the', 'how', 'what', 'does'];
const wordsOf = (search: string) => search.toLowerCase().split(/[^a-z0-9.]+/).filter(w => w.length > 2 && !STOP.includes(w));
const matchAny = (item: SearchInputItem, search: string) => {
const l = item.label.toLowerCase();
return !search || l.includes(search.toLowerCase()) || wordsOf(search).some(w => l.includes(w));
};
// marks the first place the word appears
const marked = (label: string, word: string): ReactNode => {
const at = word ? label.toLowerCase().indexOf(word.toLowerCase()) : -1;
return at < 0 ? label : <>{label.slice(0, at)}<mark>{label.slice(at, at + word.length)}</mark>{label.slice(at + word.length)}</>;
};
type Row = { kind: 'recent'; search: string } | { kind: 'source'; source: SearchInputSource } | { kind: 'item'; item: SearchInputItem };
const rowKey = (r: Row) => r.kind === 'item' ? 'i:' + r.item.label + '|' + r.item.source : r.kind === 'recent' ? 'r:' + r.search : 's:' + r.source.key;
/** Search with source filters, recent searches and an answer from the agent. Type @ to filter by source. */
export function SearchInput({ items, sources = [], recent = [], value, defaultValue = '', onValueChange, scope, defaultScope = null, onScopeChange,
open, defaultOpen = false, onOpenChange, answer, filter = matchAny, onSelect, onSubmit, placeholder = 'Search frames, files and docs',
shortcut = '⌘K', emptyLabel = 'No matches. Press Enter to ask the agent', className }: SearchInputProps) {
const [text, setText] = useControllable(value, defaultValue, onValueChange);
const [sc, setScope] = useControllable(scope, defaultScope, onScopeChange);
const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
const v = text.trim();
const root = useRef<HTMLDivElement>(null), input = useRef<HTMLInputElement>(null);
const id = useId();
// the results draw in again each time the box opens; while it stays open, only rows that weren't showing enter,
// so typing doesn't restart the list, and the list eases to its new height
const [opens, setOpens] = useState(0), [wasOpen, setWasOpen] = useState(isOpen);
if (wasOpen !== isOpen) { setWasOpen(isOpen); if (isOpen) setOpens(opens + 1); }
const at = /@\w*$/.test(v) && sources.length ? v.split('@').pop()!.toLowerCase() : null;
const found = items.filter(it => (!sc || it.source === sc) && filter(it, v));
const word = wordsOf(v).find(w => found.some(it => it.label.toLowerCase().includes(w))) || v;
const mode = at !== null ? 'sources' : !v && !sc ? 'recent' : 'results';
const rows: Row[] = mode === 'sources' ? sources.filter(s => s.key.startsWith(at!)).map(source => ({ kind: 'source', source }))
: mode === 'recent' ? recent.map(search => ({ kind: 'recent', search }))
: found.map(item => ({ kind: 'item', item }));
const paint = `${opens}|${text}|${sc}`;
const delays = useRef({ opens: -1, at: new Map<string, number>() });
if (delays.current.opens !== opens) delays.current = { opens, at: new Map() };
const shownKeys = new Set(rows.map(r => rowKey(r)));
for (const k of [...delays.current.at.keys()]) if (!shownKeys.has(k)) delays.current.at.delete(k);
let fresh = 0;
const delayOf = (k: string) => { const at = delays.current.at; if (!at.has(k)) at.set(k, fresh++ * 25); return at.get(k)!; };
const list = useRef<HTMLDivElement>(null), lastH = useRef(0), grow = useRef<Animation | null>(null), reduce = useReducedMotion();
useIsoLayoutEffect(() => {
const el = list.current; if (!el) { lastH.current = 0; return; }
const from = grow.current?.playState === 'running' ? el.getBoundingClientRect().height : lastH.current;
grow.current?.cancel(); grow.current = null;
const to = el.getBoundingClientRect().height; lastH.current = to;
if (!isOpen || !from || Math.abs(from - to) < 1 || reduce || !el.animate) return;
grow.current = el.animate([{ height: from + 'px', boxSizing: 'border-box', overflow: 'hidden' }, { height: to + 'px', boxSizing: 'border-box', overflow: 'hidden' }], { duration: 280, easing: 'cubic-bezier(.22,1,.36,1)' });
});
// the highlight only moves with the arrow keys, and starts afresh with each new list
const [hi, setHi] = useState({ at: -1, for: paint });
const cur = hi.for === paint && hi.at < rows.length ? hi.at : -1;
// a click outside lowers the box, unless the cursor is still in it
useEffect(() => {
if (!isOpen) return;
const down = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node) && document.activeElement !== input.current) setOpen(false); };
document.addEventListener('pointerdown', down);
return () => document.removeEventListener('pointerdown', down);
}, [isOpen, setOpen]);
const pick = (row: Row) => {
if (row.kind === 'recent') setText(row.search);
else if (row.kind === 'source') { setScope(row.source.label); setText(text.replace(/@\w*$/, '')); }
else onSelect?.(row.item);
};
const key = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); if (!isOpen) setOpen(true);
if (rows.length) setHi({ at: cur < 0 ? (e.key === 'ArrowDown' ? 0 : rows.length - 1) : (cur + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length, for: paint });
} else if (e.key === 'Enter') { if (cur >= 0) pick(rows[cur]); else onSubmit?.(v, sc); }
// Escape closes the list and keeps the focus in the box; a second Escape clears it
else if (e.key === 'Escape') { e.preventDefault(); if (isOpen) setOpen(false); else if (text || sc) { setText(''); setScope(null); } }
else if (e.key === 'Backspace' && !text && sc) setScope(null);
};
// rows are picked on mousedown, so the cursor stays in the box
const row = (r: Row, i: number, body: ReactNode) => {
const k = rowKey(r), e = enter(delayOf(k));
return (
<button key={k} type="button" tabIndex={-1} id={`${id}-${i}`} role="option" aria-selected={i === cur} className={cx('dm-six-it', e.className, i === cur && 'dm-hl')} style={e.style}
onMouseDown={ev => { ev.preventDefault(); pick(r); }}>{body}</button>
);
};
const words = answer?.split(' ').filter(Boolean) ?? [];
return (
<div data-dm="" ref={root} className={cx('dm-six', className)}>
<div className={cx('dm-six-box', isOpen && 'dm-f')}>
<div className="dm-six-in">
<Icon name="search" size="xs" />
<span className="dm-six-chips">
{sc && <span key={sc} className="dm-six-ch dm-enter">{sc}<button type="button" aria-label="Remove filter" onMouseDown={e => { e.preventDefault(); setScope(null); }}><Icon name="x" size="xs" /></button></span>}
</span>
<input ref={input} placeholder={placeholder} aria-label="Search" spellCheck={false} value={text} role="combobox" aria-expanded={isOpen}
aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={isOpen && cur >= 0 ? `${id}-${cur}` : undefined}
onChange={e => setText(e.target.value)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onKeyDown={key} />
<span className="dm-kbd" hidden={!!v || !!sc}>{shortcut}</span>
<button type="button" className="dm-ib dm-six-x" aria-label="Clear" hidden={!v && !sc} onMouseDown={e => { e.preventDefault(); setText(''); setScope(null); }}><Icon name="x" size="xs" /></button>
</div>
<Fold open={isOpen}>
<div key={opens} ref={list} className="dm-six-res" id={`${id}-list`} role="listbox" aria-label="Results">
{mode === 'sources' && <>
<div className="dm-six-g">Filter by source</div>
{rows.map((r, i) => r.kind === 'source' && row(r, i, <><span className="dm-ic"><Icon name={r.source.icon} size="xs" /></span>{r.source.label}<span className="dm-d">@{r.source.key}</span></>))}
</>}
{mode === 'recent' && !!rows.length && <>
<div className="dm-six-g">Recent</div>
{rows.map((r, i) => r.kind === 'recent' && row(r, i, <><span className="dm-ic"><Icon name="history" size="xs" /></span>{r.search}</>))}
</>}
{mode === 'results' && <>
{answer !== undefined && <div className="dm-six-ans"><Icon name="spark" size="xs" /><span>{words.map((w, i) => <span key={i} className="dm-w">{w} </span>)}</span></div>}
{rows.length ? <>
<div className="dm-six-g">{rows.length} {rows.length === 1 ? 'result' : 'results'}</div>
{rows.map((r, i) => r.kind === 'item' && row(r, i, <><span className="dm-ic"><Icon name={r.item.icon} size="xs" /></span><span>{marked(r.item.label, word)}</span><span className="dm-d">{r.item.source}</span></>))}
</> : <div className="dm-six-g">{emptyLabel}</div>}
</>}
</div>
</Fold>
</div>
</div>
);
}
search-input.cssReact · 29 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Search input ---------- */
/* the box sits centred in the room its longest list needs, closed or open, and stays centred as the list eases open */
.dm-six{min-height:320px;display:flex;justify-content:center;align-items:center}
.dm-six-box{width:100%;max-width:var(--dm-field-w);background:var(--dm-surface);border-radius:var(--dm-r-field);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-six-box{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
/* the edge steps up while the field has the focus; nothing else changes */
.dm-six-in{display:flex;align-items:center;gap:var(--dm-s-2);height:var(--dm-field-h);padding:0 var(--dm-s-2) 0 var(--dm-s-3);color:var(--dm-muted)}
.dm-six-in input{flex:1;min-width:0;height:100%;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--dm-fg)}
.dm-six-in input::placeholder{color:var(--dm-muted)}
.dm-six-chips{display:flex;gap:var(--dm-s-1)}
.dm-six-ch{display:inline-flex;align-items:center;gap:var(--dm-s-1);height:24px;padding:0 var(--dm-s-1) 0 var(--dm-s-2);border-radius:var(--dm-r-tag);background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));font-size:12px;font-weight:500;white-space:nowrap}
.dm-six-ch button{display:grid;opacity:.6}@media (hover:hover) and (pointer:fine){.dm-six-ch button:hover{opacity:1}}
.dm-six-x{width:24px;height:24px}
@supports (corner-shape:squircle){.dm-six-ch{corner-shape:squircle;border-radius:var(--dm-r-tag-sq)}}
.dm-six-res{padding:var(--dm-s-1) 6px var(--dm-s-2);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-six-g{font-size:11.5px;color:var(--dm-muted);padding:var(--dm-s-2) var(--dm-s-2) var(--dm-s-1)}
.dm-six-it{display:flex;align-items:center;gap:10px;width:100%;padding:7px var(--dm-s-2);border-radius:10px;font-size:13px;text-align:left;transition:background .15s}
.dm-six-it.dm-hl{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-six-it:hover{background:var(--dm-row)}}
.dm-six-it .dm-ic{color:var(--dm-muted);display:grid}
.dm-six-it .dm-d{margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-six-it mark{background:transparent;color:var(--dm-accent-fg,var(--dm-accent));font-weight:500}
.dm-six-ans{margin:var(--dm-s-1) 2px 6px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-accent-bg);font-size:12.5px;line-height:1.55;color:var(--dm-fg2);display:flex;gap:var(--dm-s-2)}
.dm-six-ans>svg{color:var(--dm-accent-fg,var(--dm-accent));flex:none;margin-top:3px}
.dm-six-ans .dm-w{animation:dm-sxw .4s var(--dm-ease) both}
.dm-six-ans .dm-w.dm-done{animation:none}
@keyframes dm-sxw{from{opacity:0;filter:blur(3px)}}
search-input.htmlHTML · 11 lines
<div class="card" data-c="search-input">
<div class="stage-in">
<div class="six">
<div class="six-box">
<div class="six-in"><svg class="i xs"><use href="#i-search"/></svg><span class="six-chips"></span><input placeholder="Search frames, files and docs" aria-label="Search" spellcheck="false"><span class="kbd six-k">⌘K</span><button class="ib six-x" aria-label="Clear" hidden><svg class="i xs"><use href="#i-x"/></svg></button></div>
<div class="fold shut six-dd"><div><div class="six-res"></div></div></div>
</div>
</div>
</div>
</div>
search-input.cssHTML · 28 lines
/* ---------- Search input ---------- */
/* the box sits centred in the room its longest list needs, closed or open, and stays centred as the list eases open */
.six{min-height:320px;display:flex;justify-content:center;align-items:center}
.six-box{width:100%;max-width:var(--field-w);background:var(--surface);border-radius:var(--r-field);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.six-box{corner-shape:squircle;border-radius:var(--r-field-sq)}}
/* the edge steps up while the field has the focus; nothing else changes */
.six-in{display:flex;align-items:center;gap:var(--s-2);height:var(--field-h);padding:0 var(--s-2) 0 var(--s-3);color:var(--muted)}
.six-in input{flex:1;min-width:0;height:100%;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--fg)}
.six-in input::placeholder{color:var(--muted)}
.six-chips{display:flex;gap:var(--s-1)}
.six-ch{display:inline-flex;align-items:center;gap:var(--s-1);height:24px;padding:0 var(--s-1) 0 var(--s-2);border-radius:var(--r-tag);background:var(--accent-bg);color:var(--accent-fg,var(--accent));font-size:12px;font-weight:500;white-space:nowrap}
.six-ch button{display:grid;opacity:.6}@media (hover:hover) and (pointer:fine){.six-ch button:hover{opacity:1}}
.six-x{width:24px;height:24px}
@supports (corner-shape:squircle){.six-ch{corner-shape:squircle;border-radius:var(--r-tag-sq)}}
.six-res{padding:var(--s-1) 6px var(--s-2);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.six-g{font-size:11.5px;color:var(--muted);padding:var(--s-2) var(--s-2) var(--s-1)}
.six-it{display:flex;align-items:center;gap:10px;width:100%;padding:7px var(--s-2);border-radius:10px;font-size:13px;text-align:left;transition:background .15s}
.six-it.hl{background:var(--row)}
@media (hover:hover) and (pointer:fine){.six-it:hover{background:var(--row)}}
.six-it .ic{color:var(--muted);display:grid}
.six-it .d{margin-left:auto;font-size:12px;color:var(--muted)}
.six-it mark{background:transparent;color:var(--accent-fg,var(--accent));font-weight:500}
.six-ans{margin:var(--s-1) 2px 6px;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--accent-bg);font-size:12.5px;line-height:1.55;color:var(--fg2);display:flex;gap:var(--s-2)}
.six-ans>svg{color:var(--accent-fg,var(--accent));flex:none;margin-top:3px}
.six-ans .w{animation:sxw .4s var(--ease) both}
.six-ans .w.done{animation:none}
@keyframes sxw{from{opacity:0;filter:blur(3px)}}
search-input.jsHTML · 56 lines
DM.def('search-input', (r, c) => {
r.innerHTML = r.innerHTML.replace('{search}', I('search','xs')).replace('{x}', I('x','xs'));
const box = $('.six-box', r), inp = $('.six-in input', r), dd = $('.six-dd', r), res = $('.six-res', r), chips = $('.six-chips', r), xB = $('.six-x', r), k = $('.six-k', r);
const ITEMS = [['figma','Pricing v4','Figma'],['figma','Pricing mobile','Figma'],['file-text','Pricing research notes','Notion'],['code','PricingCard.tsx','GitHub'],['figma','Checkout flow','Figma'],['file-text','Brand voice guide','Notion'],['code','tokens.css','GitHub']];
const SC = { figma:'Figma', notion:'Notion', github:'GitHub' };
let scope = null, at = null, ans = 0, rows = new Set(), grow = null, hi = -1;
res.id = 'six-r-' + Math.random().toString(36).slice(2, 7); res.setAttribute('role', 'listbox'); res.setAttribute('aria-label', 'Suggestions');
inp.setAttribute('role', 'combobox'); inp.setAttribute('aria-autocomplete', 'list'); inp.setAttribute('aria-expanded', 'false'); inp.setAttribute('aria-controls', res.id);
// the agent's answer streams in word by word and keeps going while the question is still being typed
const WS = 'Pricing changed on Sep 18: annual plans first, one CTA per card. Trial to paid is up 8.1 points since.'.split(' ');
function paint(){
// the list eases to its new height instead of jumping as the results change
const h0 = dd.classList.contains('shut') ? 0 : res.offsetHeight; if (grow) grow.cancel();
const v = inp.value.trim(); xB.hidden = !v && !scope; k.hidden = !!v || !!scope;
let h = '';
if (/@\w*$/.test(v)){ const q = v.split('@').pop().toLowerCase(); h = '<div class="six-g">Filter by source</div>' + Object.entries(SC).filter(([key]) => key.startsWith(q)).map(([key, n]) => `<button class="six-it" data-sc="${n}"><span class="ic">${I(key === 'figma' ? 'figma' : key === 'github' ? 'code' : 'file-text','xs')}</span>${n}<span class="d">@${key}</span></button>`).join(''); }
else if (!v && !scope){ h = '<div class="six-g">Recent</div>' + ['pricing contrast','checkout copy','tokens'].map(q => `<button class="six-it" data-q="${q}"><span class="ic">${I('history','xs')}</span>${q}</button>`).join(''); }
else {
const words = v.toLowerCase().split(/[^a-z0-9.]+/).filter(w => w.length > 2 && !['why','did','the','how','what','does'].includes(w)); const hit = i => !v || i[1].toLowerCase().includes(v.toLowerCase()) || words.some(w => i[1].toLowerCase().includes(w));
const m = ITEMS.filter(i => (!scope || i[2] === scope) && hit(i)); const hw = words.find(w => m.some(i => i[1].toLowerCase().includes(w))) || v;
if (v.split(' ').length > 2 || v.endsWith('?')) h += `<div class="six-ans">${I('spark','xs')}<span class="six-at"></span></div>`;
h += m.length ? '<div class="six-g">' + m.length + (m.length === 1 ? ' result' : ' results') + '</div>' + m.map(i => `<button class="six-it"><span class="ic">${I(i[0],'xs')}</span><span>${hw ? i[1].replace(new RegExp(hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'), s => `<mark>${s}</mark>`) : i[1]}</span><span class="d">${i[2]}</span></button>`).join('') : '<div class="six-g">No matches. Press Enter to ask the agent</div>';
}
res.innerHTML = h; hi = -1; inp.removeAttribute('aria-activedescendant');
$$('.six-it', res).forEach((b, j) => { b.id = res.id + '-' + j; b.setAttribute('role', 'option'); b.setAttribute('aria-selected', 'false'); b.tabIndex = -1; });
// only rows that weren't showing a moment ago enter, so typing doesn't restart the whole list
const now = $$('.six-it', res), fresh = now.filter(b => !rows.has(b.textContent)); rows = new Set(now.map(b => b.textContent)); stagger(fresh, 25);
const a = $('.six-at', res);
if (a){ a.innerHTML = WS.slice(0, ans).map(w => `<span class="w done">${w} </span>`).join('');
if (!at && ans < WS.length) at = c.every(45, () => { const el = $('.six-at', res); if (!el || ans >= WS.length){ clearInterval(at); at = null; return; } el.insertAdjacentHTML('beforeend', `<span class="w">${WS[ans++]} </span>`); }); }
else { if (at){ clearInterval(at); at = null; } ans = 0; }
const h1 = res.offsetHeight;
if (h0 && h1 !== h0 && !REDUCE) grow = res.animate([{ height:h0 + 'px', boxSizing:'border-box', overflow:'hidden' }, { height:h1 + 'px', boxSizing:'border-box', overflow:'hidden' }], { duration:280, easing:'cubic-bezier(.22,1,.36,1)' });
$$('.six-it[data-q]', res).forEach(b => b.onmousedown = e => { e.preventDefault(); inp.value = b.dataset.q; paint(); });
$$('.six-it[data-sc]', res).forEach(b => b.onmousedown = e => { e.preventDefault(); scope = b.dataset.sc; inp.value = inp.value.replace(/@\w*$/, ''); chips.innerHTML = `<span class="six-ch">${scope}<button aria-label="Remove filter">${I('x','xs')}</button></span>`; enter(chips.firstChild); $('button', chips).onmousedown = ev => { ev.preventDefault(); scope = null; chips.innerHTML = ''; paint(); }; paint(); });
}
function shut(){ box.classList.remove('f'); dd.classList.add('shut'); inp.setAttribute('aria-expanded', 'false'); }
const list = () => { dd.classList.remove('shut'); inp.setAttribute('aria-expanded', 'true'); };
inp.onfocus = () => { box.classList.add('f'); list(); paint(); };
inp.onblur = shut;
inp.oninput = () => { list(); paint(); };
// the box is a combobox for the list under it: up and down move through the rows and wrap, Enter picks the highlighted
// one, and Escape closes the list, keeping the focus in the box (a second Escape clears it)
const light = k => { const all = $$('.six-it', res); if (!all.length) return; hi = (k + all.length) % all.length;
all.forEach((b, j) => { b.classList.toggle('hl', j === hi); b.setAttribute('aria-selected', j === hi); }); inp.setAttribute('aria-activedescendant', all[hi].id); all[hi].scrollIntoView({ block:'nearest' }); };
inp.onkeydown = e => {
if (e.key === 'Backspace' && !inp.value && scope){ scope = null; chips.innerHTML = ''; paint(); }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); if (dd.classList.contains('shut')){ list(); paint(); } light(hi < 0 ? (e.key === 'ArrowDown' ? 0 : -1) : hi + (e.key === 'ArrowDown' ? 1 : -1)); }
if (e.key === 'Enter' && hi >= 0 && !dd.classList.contains('shut')){ const b = $$('.six-it', res)[hi]; if (b && b.onmousedown){ e.preventDefault(); b.onmousedown(e); } }
if (e.key === 'Escape'){ e.preventDefault(); if (!dd.classList.contains('shut')){ dd.classList.add('shut'); inp.setAttribute('aria-expanded', 'false'); inp.removeAttribute('aria-activedescendant'); } else if (inp.value || scope){ inp.value = ''; scope = null; chips.innerHTML = ''; paint(); } } };
xB.onmousedown = e => { e.preventDefault(); inp.value = ''; scope = null; chips.innerHTML = ''; paint(); };
c.on(document, 'pointerdown', e => { if (!r.contains(e.target) && document.activeElement !== inp) shut(); });
// the preview types a question on its own; clicking, focusing or typing in it takes over, and what it had typed goes
c.auto(900, () => c.run(async () => { box.classList.add('f'); c.taken(() => { if (document.activeElement !== inp) shut(); }); dd.classList.remove('shut'); paint(); await c.step(600); await c.type(inp, 'why did pricing change?', { ms:45, each:paint }); }));
});
Questions
What people ask about it.
- How do I stream the agent's answer into the results?
- Pass the text so far as
answerand update it as tokens arrive. Each word is its own span, so new words fade in while earlier ones stay put. - What happens when the person presses Enter without picking a result?
onSubmitis called with the trimmed search and the current source filter, or null. That is the place to send the question to your agent.- Can I use my own matching, like fuzzy search?
- Yes. Pass
filter(item, search)and return true for the items to keep. The source filter is still applied first.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.