Live Search Demo

A self-contained live-search panel — a debounced text input, a filtered result list, substring match highlighting, and an empty-state message. Designed for lessons that explain debouncing, derived state, and "filter + show results" UI without coupling to a router or a network engine.

Preview
12 items
  • ApplePome fruit
  • ApricotStone fruit
  • BananaTropical
  • BlueberryBerry
  • CherryStone fruit
  • DateDried fruit
  • ElderberryBerry
  • FigMultiple fruit
Customize
Behaviour
150
8
Display

Installation

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

Usage

import { LiveSearchDemo, type LiveSearchDemoItem } from "@craft-bits/core";
 
const items: LiveSearchDemoItem[] = [
  { id: "apple", text: "Apple", description: "Pome fruit" },
  { id: "banana", text: "Banana", description: "Tropical" },
];
 
export function Demo() {
  return (
    <LiveSearchDemo
      items={items}
      debounceMs={150}
      onSelect={(item) => console.log(item)}
    />
  );
}

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

Anatomy

  • Input row. A role="searchbox" input with an optional leading magnifier glyph. A small accent dot pulses to the right while the debounce timer is pending.
  • Count row. A polite live region that announces the item count, or the match count once the query is non-empty.
  • Result list. A role="list" of rows. Each row animates in/out with a short stagger and short-circuits under reduced motion.
  • Empty state. A muted, italic row rendered when the query is non-empty and no items match.

Understanding the component

  1. Two queries. The raw input value drives the visible field; the debounced value drives filtering. Whenever the raw value changes a single timer is (re-)armed; on fire it copies the raw value into the debounced one and the result list recomputes via useMemo.
  2. Debounce. Tune via debounceMs. Pass 0 to short-circuit the timer — every keystroke filters synchronously. A single setTimeout is tracked in a ref so consecutive keystrokes cancel any previous fire.
  3. Filter. The default predicate is a case-insensitive substring match against text and any keywords. Override per-instance via the filter prop.
  4. Highlight. highlightLiveSearchMatch(text, query) wraps every case-insensitive occurrence of the query in a <mark>. Disable per-row via hideHighlight, or pass renderItem for full control.
  5. Motion. Result rows enter and exit with SPRINGS.snap and a short stagger; the empty-state row fades with SPRINGS.damped. Every transition short-circuits to instant under prefers-reduced-motion.

Props

LiveSearchDemo

PropTypeDefaultDescription
itemsLiveSearchDemoItem[]requiredList filtered against the query.
query / defaultQuerystring''Controlled / uncontrolled query value.
onQueryChange(next) => voidFires on every debounced query change.
onSelect(item) => voidFires when the user picks a row.
filter(item, query) => booleansubstringMatch predicate.
maxResultsnumber8Hard cap on the result list.
debounceMsnumber120Input debounce before filtering. 0 disables.
placeholderstring'Type to search…'Placeholder text.
ariaLabelstring'Search items'Accessible name for the input.
hideIconbooleanfalseHide the leading magnifier.
hideHighlightbooleanfalseSkip the highlight wrap on rows.
hideCountbooleanfalseHide the result-count strip.
renderItem(item, info) => ReactNodeCustom row renderer.
emptyLabelReactNode'No matches'Empty-state message.

LiveSearchDemoItem

FieldTypeDescription
idstringStable identifier.
textstringVisible label and default match target.
descriptionReactNodeOptional sub-label rendered in a muted tone.
keywordsstring[]Optional extra match terms folded into the default filter.

Accessibility

  • The input carries role="searchbox" and an explicit aria-label (override via ariaLabel).
  • The count row is role="status" with aria-live="polite" so screen readers announce result counts as the user types.
  • Each row is a focusable list item when onSelect is wired; Enter and Space both fire the select handler.
  • All animations short-circuit under prefers-reduced-motion — rows simply pop in and out.
  • data-cb-edu="live-search-demo" rides the wrapper and data-cb-pending="true | false" exposes the debounce-pending state for tone-specific styling.

Credits

  • Extracted from: terminal-dreams (src/components/recipe-lab/live-search/search-demo.tsx). The original bundled a 9-step recipe-lab tour — feature-toggle toolbar, a step-3 "naive useEffect" toggle and stale-frame indicator, a step-7/8 "growing pains" prop counter, a layout picker, and a step-9 slot rearranger — all wired through a project-level SearchDemo context that exposed activeStep, userOverride, stepFeatures, slotOrder, and a StateInspector. This rewrite drops the tour scaffolding, the inspector coupling, the prop-cost narrative, and the slot router, and keeps the highest-value primitive: a debounced live-search panel that any lesson can drop in to explain "input → debounce → derived list → highlighted matches."