Skip to content
Star on GitHub
Forms and inputsSearch input
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Search 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.

$npx designme add search-input
⌘K
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add search-input

React

$npx designme add search-input --react

The 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.

02

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
03

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>
  );
}
04

Props

Everything SearchInput takes.

PropTypeWhat it does
itemsrequiredSearchInputItem[]
sourcesSearchInputSource[]Typing @ lists these, and picking one adds it as a filter chip
recentstring[]Earlier searches, shown while the box is empty
valuestringWhat is typed. Leave it out to let the box hold its own
defaultValuestring
onValueChange(value: string) => void
scopestring | nullThe source filter, by label, or null. Backspace in an empty box removes it
defaultScopestring | null
onScopeChange(scope: string | null) => void
openbooleanWhether the box is lifted with its results open. Focus opens it and leaving closes it when you leave it out
defaultOpenboolean
onOpenChange(open: boolean) => void
answerstringAn answer from the agent, shown above the results. Feed it as it grows and each new word fades in
filter(item: SearchInputItem, search: string) => booleanWhich items match the search. By default an item matches the whole search or any word longer than two letters
onSelect(item: SearchInputItem) => voidCalled when a result is picked, by click or Enter
onSubmit(search: string, scope: string | null) => voidCalled by Enter when no row is highlighted, with the search and the source filter
placeholderstring
shortcutstringThe key hint at the end of the empty box
emptyLabelstringWhat the list says when nothing matches
classNamestring
05

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 }); }));
});
06

Questions

What people ask about it.

How do I stream the agent's answer into the results?
Pass the text so far as answer and 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?
onSubmit is 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.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
OTP fieldA one-time code, one box per digit, that fills from a paste.
All 11 in Forms and inputs →