Search
Command search with live filtering and an empty state.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useState } from "react";
import GlideMenu from "./GlideMenu";
/* ─────────────────────────────────────────────────────────
* SEARCH — command search with live filtering.
* The field, clear action, and results are directly usable.
* ───────────────────────────────────────────────────────── */
export type SearchItem = string;
export type SearchListLabels = {
placeholder: string;
ariaLabel: string;
emptyTitle: string;
emptyHint: string;
};
const ITEMS: SearchItem[] = [
"Forecast summer demand",
"Find design asset suppliers",
"Compare seasonal projects",
"Draft project launch plan",
"Check cold-chain status",
"Audit sugar costs",
"Retire low sellers",
];
const LABELS: SearchListLabels = {
placeholder: "Search projects…",
ariaLabel: "Search projects",
emptyTitle: "No results found",
emptyHint: "Adjust your search to try again",
};
export default function SearchList({
items = ITEMS,
labels = LABELS,
}: {
items?: SearchItem[];
labels?: SearchListLabels;
variant?: string;
} = {}) {
const [query, setQuery] = useState("");
const results = query
? items.filter((i) => i.toLowerCase().includes(query.toLowerCase()))
: items.slice(0, 5);
const empty = query.length > 2 && results.length === 0;
return (
<div className="flex min-h-[248px] w-full max-w-72 flex-col items-stretch">
<div className="w-full self-start overflow-hidden rounded-card bg-card shadow-raised">
{/* input row */}
<div className="flex h-10 items-center gap-2 border-b border-line px-3 transition-colors duration-100 hover:bg-hover">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={labels.placeholder}
aria-label={labels.ariaLabel}
className="min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3"
/>
{query && (
<button
aria-label="Clear search"
type="button"
onClick={() => setQuery("")}
className="flex size-6 items-center justify-center rounded-full text-ink-3
transition-colors duration-100 hover:bg-line/70 hover:text-ink"
style={{ animation: "fade-in 150ms ease-out both" }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* results / empty state */}
{empty ? (
<div className="flex flex-col items-center justify-center gap-1 px-4 py-8" style={{ animation: "fade-in 250ms ease-out both" }}>
<span className="mb-1.5 flex size-8 items-center justify-center rounded-control bg-inset text-ink-3 shadow-hairline">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
</span>
<span className="text-[13px] font-medium text-ink">{labels.emptyTitle}</span>
<span className="text-[12px] text-ink-3">{labels.emptyHint}</span>
</div>
) : (
<div className="p-1">
<GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[6px] bg-hover">
{results.map((item) => (
<button
key={item}
data-menu-row
type="button"
onClick={() => setQuery(item)}
className="relative z-10 flex h-8 w-full items-center rounded-[6px] px-2 text-left text-[13px] text-ink"
style={{ animation: "fade-in 200ms ease-out both" }}
>
{item}
</button>
))}
</GlideMenu>
</div>
)}
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/searchnpx shadcn@latest add @ward/searchyarn dlx shadcn@latest add @ward/searchbunx --bun shadcn@latest add @ward/searchAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/search.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundation @ward/glide-menuCopy each file from the Code tab into:
components/ward/SearchList.tsx
Update the import paths to match your project.
Usage
import SearchList from "@/components/ward/SearchList";<SearchList
items={["Forecast summer demand", "Audit sugar costs", "Retire low sellers"]}
labels={{
placeholder: "Search tasks…",
ariaLabel: "Search tasks",
emptyTitle: "No results found",
emptyHint: "Adjust your search to try again",
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; the component has a single variant. |
items | SearchItem[] | ITEMS | Strings to search, with the first five shown while the field is empty. |
labels | SearchListLabels | LABELS | Placeholder, accessible label and empty-state text for the search field. |
Ward · Version 1.1.0 · Registry manifest