Patterns
PatternsTier 5 · Blocks
Search
A combobox field with a suggestion listbox and full keyboard support.
bunx --bun shadcn@latest add @woohl/searchPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/searchregistry/tier-5-blocks/search.tsx
"use client";
import {
useId,
useRef,
useState,
type ReactNode,
type ReactElement,
} from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import {
useClickOutside,
useEscapeKey,
} from "@/registry/tier-2-primitives/lib/accessibility";
import { focusRing } from "@/registry/tier-2-primitives/lib/cva";
import { CloseGlyph } from "@/registry/tier-2-primitives/glyphs";
/**
* Search.
*
* The input, and the suggestion list underneath it.
*
* THIS IS A COMBOBOX, AND COMBOBOXES HAVE RULES
* `role="combobox"` on the input, `aria-expanded`, `aria-controls` pointing
* at the listbox, and `aria-activedescendant` naming the highlighted option.
* Focus stays in the input while Down/Up move the highlight — that is what
* makes it possible to type, arrow to a suggestion and keep typing. A
* suggestion list built from divs and a `useState` index is a list a screen
* reader cannot see at all.
*
* The live region announces how many suggestions appeared, because otherwise a
* non-visual user has no way to know anything happened when they stopped
* typing.
*/
export type SearchSuggestion = {
id: string;
label: string;
/** "in Home & living", "maker", "3 results". */
meta?: string;
/** Something to show beside it — a thumbnail, an avatar. */
leading?: ReactNode;
};
export type SearchProps = {
value: string;
onValueChange: (value: string) => void;
onSubmit?: (value: string) => void;
onSelect?: (suggestion: SearchSuggestion) => void;
suggestions?: SearchSuggestion[];
placeholder?: string;
/** The field's accessible name. */
label?: string;
size?: "sm" | "md" | "lg";
loading?: boolean;
/**
* The leading magnifier, from the app's own icon set:
* `icon={<Icon name="search" />}`. Omitted renders no glyph — the field is
* still labelled, so the icon is decoration rather than the affordance.
*/
icon?: ReactElement;
className?: string;
};
export function Search({
icon,
value,
onValueChange,
onSubmit,
onSelect,
suggestions = [],
placeholder = "Search makers, pieces, materials",
label = "Search",
size = "md",
loading = false,
className,
}: SearchProps) {
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(-1);
const expanded = open && suggestions.length > 0;
useEscapeKey(open, () => setOpen(false));
const containerRef = useClickOutside<HTMLDivElement>(open, () =>
setOpen(false),
);
function choose(suggestion: SearchSuggestion) {
onSelect?.(suggestion);
onValueChange(suggestion.label);
setOpen(false);
setHighlighted(-1);
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (!expanded) {
if (event.key === "Enter") onSubmit?.(value);
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setHighlighted((index) => (index + 1) % suggestions.length);
break;
case "ArrowUp":
event.preventDefault();
setHighlighted(
(index) => (index - 1 + suggestions.length) % suggestions.length,
);
break;
case "Enter":
event.preventDefault();
{
const picked =
highlighted >= 0 ? suggestions[highlighted] : undefined;
if (picked) choose(picked);
else onSubmit?.(value);
}
break;
}
}
const height =
size === "sm"
? "h-[var(--control-h-sm)]"
: size === "lg"
? "h-[var(--control-h-lg)]"
: "h-[var(--control-h-md)]";
return (
<div
data-slot="search"
ref={containerRef}
className={cn("font-ui relative", className)}
>
<div
className={cn(
"rounded-pill border-border-subtle bg-surface-raised flex items-center gap-2.5 border px-4",
"motion-instant transition-[border-color,box-shadow]",
"focus-within:border-border-accent focus-within:shadow-focus",
height,
)}
>
{icon && (
<span
aria-hidden
className="text-text-muted grid flex-none place-items-center [&>svg]:size-[17px]"
>
{icon}
</span>
)}
<input
ref={inputRef}
type="search"
role="combobox"
aria-label={label}
aria-expanded={expanded}
aria-controls={expanded ? listId : undefined}
aria-autocomplete="list"
aria-activedescendant={
expanded && highlighted >= 0
? `${listId}-option-${highlighted}`
: undefined
}
value={value}
placeholder={placeholder}
onChange={(event) => {
onValueChange(event.target.value);
setOpen(true);
setHighlighted(-1);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
className={cn(
"text-body text-text-primary min-w-0 flex-1 border-none bg-transparent outline-none",
"placeholder:text-text-muted",
// The browser's own clear button duplicates ours and can't be styled.
"[&::-webkit-search-cancel-button]:appearance-none",
)}
/>
{loading && (
<span
data-woohl-motion="loop"
aria-hidden
className="animate-woohl-spin border-border-subtle border-t-surface-accent size-4 flex-none rounded-full border-2"
/>
)}
{value && !loading && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
onValueChange("");
setHighlighted(-1);
inputRef.current?.focus();
}}
className={cn(
"text-text-muted hover:text-text-primary grid place-items-center rounded-full",
focusRing,
)}
>
<CloseGlyph size={16} />
</button>
)}
</div>
{/* Announced politely, so a screen-reader user knows the list changed
without being interrupted mid-keystroke. */}
<span className="sr-only" role="status" aria-live="polite">
{expanded ? `${suggestions.length} suggestions available` : ""}
</span>
{expanded && (
<ul
id={listId}
role="listbox"
aria-label={`${label} suggestions`}
className={cn(
"z-dropdown animate-woohl-rise absolute top-[calc(100%+6px)] left-0 w-full origin-top",
"border-border-subtle bg-surface-raised shadow-e2 overflow-hidden rounded-xl border py-1.5",
)}
>
{suggestions.map((suggestion, index) => (
<li
key={suggestion.id}
id={`${listId}-option-${index}`}
role="option"
aria-selected={index === highlighted}
onMouseEnter={() => setHighlighted(index)}
onPointerDown={(event) => {
// Pointerdown, not click — click fires after blur, by which
// point the list has already closed.
event.preventDefault();
choose(suggestion);
}}
className={cn(
"text-body flex cursor-pointer items-center gap-3 px-4 py-2.5",
index === highlighted ? "bg-surface-sunken" : "bg-transparent",
)}
>
{suggestion.leading}
<span className="text-text-primary flex-1 truncate">
{suggestion.label}
</span>
{suggestion.meta && (
<span className="text-caption text-text-muted flex-none">
{suggestion.meta}
</span>
)}
</li>
))}
</ul>
)}
</div>
);
}
SearchProps
| Prop | Type | Default |
|---|---|---|
onValueChange* | (value: string) => void | — |
value* | string | — |
className | string | — |
iconThe leading magnifier, from the app's own icon set: `icon={<Icon name="search" />}`. Omitted renders no glyph — the field is still labelled, so the icon is decoration rather than the affordance. | ReactElement | — |
labelThe field's accessible name. | string | — |
loading | boolean | — |
onSelect | (suggestion: SearchSuggestion) => void | — |
onSubmit | (value: string) => void | — |
placeholder | string | — |
size | "sm" | "md" | "lg" | — |
suggestions | SearchSuggestion[] | — |
Registry items
npm packages
None beyond React itself.