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.jsonUsage
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
- 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. - Debounce. Tune via
debounceMs. Pass0to short-circuit the timer — every keystroke filters synchronously. A singlesetTimeoutis tracked in a ref so consecutive keystrokes cancel any previous fire. - Filter. The default predicate is a case-insensitive substring match against
textand anykeywords. Override per-instance via thefilterprop. - Highlight.
highlightLiveSearchMatch(text, query)wraps every case-insensitive occurrence of the query in a<mark>. Disable per-row viahideHighlight, or passrenderItemfor full control. - Motion. Result rows enter and exit with
SPRINGS.snapand a short stagger; the empty-state row fades withSPRINGS.damped. Every transition short-circuits to instant underprefers-reduced-motion.
Props
LiveSearchDemo
| Prop | Type | Default | Description |
|---|---|---|---|
items | LiveSearchDemoItem[] | required | List filtered against the query. |
query / defaultQuery | string | '' | Controlled / uncontrolled query value. |
onQueryChange | (next) => void | — | Fires on every debounced query change. |
onSelect | (item) => void | — | Fires when the user picks a row. |
filter | (item, query) => boolean | substring | Match predicate. |
maxResults | number | 8 | Hard cap on the result list. |
debounceMs | number | 120 | Input debounce before filtering. 0 disables. |
placeholder | string | 'Type to search…' | Placeholder text. |
ariaLabel | string | 'Search items' | Accessible name for the input. |
hideIcon | boolean | false | Hide the leading magnifier. |
hideHighlight | boolean | false | Skip the highlight wrap on rows. |
hideCount | boolean | false | Hide the result-count strip. |
renderItem | (item, info) => ReactNode | — | Custom row renderer. |
emptyLabel | ReactNode | 'No matches' | Empty-state message. |
LiveSearchDemoItem
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier. |
text | string | Visible label and default match target. |
description | ReactNode | Optional sub-label rendered in a muted tone. |
keywords | string[] | Optional extra match terms folded into the default filter. |
Accessibility
- The input carries
role="searchbox"and an explicitaria-label(override viaariaLabel). - The count row is
role="status"witharia-live="polite"so screen readers announce result counts as the user types. - Each row is a focusable list item when
onSelectis 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 anddata-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-levelSearchDemocontext that exposedactiveStep,userOverride,stepFeatures,slotOrder, and aStateInspector. 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."