Search Components

A compound autocomplete surface — an input, a results dropdown, and an optional suggestion rail. The three parts discover each other through context so the consumer can lay them out freely. Pass items, plug a custom filter and onSelect, and the widget covers any "list + query + pick" use case.

Preview
Try
Customize
Behaviour
8
Display

Installation

npx shadcn@latest add https://craftbits.dev/r/search-components.json

Usage

import { SearchComponents, type SearchComponentsItem } from "@craft-bits/core";
 
const items: SearchComponentsItem[] = [
  { id: "trie", label: "Prefix Trie", description: "Trie cache" },
  { id: "debounce", label: "Debounce", description: "Input rate limit" },
];
 
export function Demo() {
  return (
    <SearchComponents.Root items={items} onSelect={(item) => console.log(item)}>
      <SearchComponents.Box placeholder="Search…" />
      <SearchComponents.Results />
      <SearchComponents.Suggestions title="Try" />
    </SearchComponents.Root>
  );
}

Swap the filter for prefix-only, fuzzy, or whatever model the consumer wants — the signature is (item, query) => boolean.

Anatomy

  • Root. Holds the query and open state, computes the filtered results, and exposes them via context. Accepts controlled query / onQueryChange and open / onOpenChange props for full external control.
  • Box. The combobox-compliant input. Owns ArrowDown / ArrowUp / Enter / Escape, mirrors aria-activedescendant, and renders a leading magnifier glyph plus a trailing "Esc to clear" pill.
  • Results. A role="listbox" dropdown that animates in when the query is non-empty. Each row is role="option" with aria-selected mirroring the highlight. Substring matches are wrapped in a <mark> by default.
  • Suggestions. A chip rail that only shows when the query is empty. Picking a chip fills the input with that label.

Understanding the component

  1. Compound context. Root builds a single context value and renders its children inside a wrapping div. Each child part reads that value via useContext — the parts can sit in any order, but Box and Results commonly stack so Results can absolute-position underneath.
  2. Filter. The default predicate is a case-insensitive substring match on label. Override per-Root via filter.
  3. Keyboard. ArrowDown / ArrowUp move the highlight and open the dropdown if it was closed. Enter selects the highlighted row when the query is non-empty. Escape clears the query on first press, then blurs on the second.
  4. Highlighted match. Results wraps the first case-insensitive occurrence of the query in each label with a <mark>. Disable via hideHighlight, or pass renderItem for full control.
  5. Motion. The Results panel grows in with SPRINGS.snap and fades out. Animations short-circuit under prefers-reduced-motion.

Props

SearchComponents.Root

PropTypeDefaultDescription
itemsSearchComponentsItem[]requiredSource list filtered against the query.
suggestionsSearchComponentsItem[]first 5 of itemsChips shown when the query is empty.
filter(item, query) => booleansubstringMatch predicate.
query / defaultQuerystring''Controlled / uncontrolled query value.
onQueryChange(next) => voidFires on every query mutation.
onSelect(item) => voidFires when the user picks a row.
maxResultsnumber8Hard cap on the result list.
open / onOpenChangebooleanuncontrolledOverride the open state.

SearchComponents.Box

PropTypeDefaultDescription
placeholderstring'Search…'Placeholder text.
ariaLabelstring'Search'Accessible name for the input.
hideIconbooleanfalseHide the leading magnifier glyph.
hideClearbooleanfalseHide the trailing Esc pill.

SearchComponents.Results

PropTypeDefaultDescription
hideHighlightbooleanfalseSkip the highlight wrap on labels.
renderItem(item, info) => ReactNodeCustom row renderer.
emptyLabelReactNode'No results'Empty-state message.

SearchComponents.Suggestions

PropTypeDefaultDescription
titleReactNode'Try'Caption above the chip rail.
onPick(item) => voidFires when a suggestion chip is pressed.

SearchComponentsItem

FieldTypeDescription
idstringStable identifier.
labelstringVisible label and default match target.
descriptionReactNodeOptional sub-label rendered in a muted tone.
valueunknownFree-form payload surfaced back via onSelect.

Accessibility

  • The input carries role="combobox", aria-expanded, aria-controls, aria-autocomplete="list", and aria-activedescendant so screen readers announce the highlighted option as the user arrows through results.
  • The dropdown is role="listbox" with aria-label="Search results". Each row is role="option" with aria-selected mirroring the highlight.
  • The Esc clear pill has an explicit aria-label="Clear search".
  • All animations short-circuit under prefers-reduced-motion — the dropdown still toggles, just without the slide.
  • data-cb-edu="search-components" and data-cb-open="true | false" ride the wrapper for tone-specific styling without monkey-patching.

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/sdp-autocomplete/ui/SearchComponents.tsx). The original bundled four pieces — PersistentSearch, NetworkTimeline, TrieVisualizer, and a highlightMatch helper — all bound to a project-level useAutocomplete context that wired a prefix-trie engine, an in-flight request store, and a feature-flag set ('debounce', 'abortController', 'trieCache', 'keyboardNav', 'matchHighlight', 'generationCounter', 'networkError', 'accessibility'). This rewrite drops the engine coupling, the network timeline, the trie visualiser, and the feature-flag badges, and generalises the surface to a context-driven compound — SearchComponents.Root / .Box / .Results / .Suggestions — with items + filter + onSelect as the only required wiring.