No results found Adjust your search to try again
UI Components
Search.
Command search with live filtering and an empty state.
NavigationComponent1 dependency Beautiful UI, MIT
Using Claude Code? Give it the agent prompt and skip these steps.
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import SearchList from "../components/viewership/search/SearchList.astro"; --- <SearchList />
Props 4
| Prop | Type | Default | Description |
|---|---|---|---|
items | string[] | · | Items. Case-insensitive substring match. |
limit | number | 5 | Suggestions shown for an empty query. |
value | string | "" | Initial query. Works without JS. |
labels | Partial<SearchListLabels> | · | placeholder, ariaLabel, listLabel, emptyTitle, emptyHint, clear, results ("{n} results"), oneResult. |
Events
| Event | Detail | When |
|---|---|---|
vs-search-select | CustomEvent<{ value: string; index: number }> | Fires on pick. index points into items. |
vs-search-input | CustomEvent<{ query: string; count: number }> | Fires on each query change. |
Source 1 file
---
/**
* SearchList: command search with live filtering and an empty state.
*
* Ported from Beautiful UI `components/primitives/SearchList.tsx`
* (MIT, © 2026 Shane Levine).
*
* Accessibility: WAI-ARIA combobox with an inline listbox. Focus stays in
* the field; ↑/↓ move the active option, Enter picks it, Escape clears.
* Result counts are announced politely.
*
* Events (bubbling, on the root):
* vs-search-select detail: { value, index } (index into `items`)
* vs-search-input detail: { query, count }
*
* @example
* <SearchList items={["Build the weekly report", "Check AI citations"]} />
*/
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";
export interface SearchListLabels {
placeholder: string;
/** Accessible name of the field. */
ariaLabel: string;
/** Accessible name of the results list. */
listLabel: string;
emptyTitle: string;
emptyHint: string;
clear: string;
/** Announced result count; {n} is replaced. */
results: string;
oneResult: string;
}
type Props = HTMLAttributes<"div"> & {
items: string[];
labels?: Partial<SearchListLabels>;
/** How many items show while the query is empty. */
limit?: number;
/** Initial query. */
value?: string;
};
const { items = [], labels, limit = 5, value = "", class: className, ...rest } = Astro.props;
const l: SearchListLabels = {
placeholder: "Search…",
ariaLabel: "Search",
listLabel: "Results",
emptyTitle: "No results found",
emptyHint: "Adjust your search to try again",
clear: "Clear search",
results: "{n} results",
oneResult: "1 result",
...labels,
};
const uid = `vs-search-${Math.random().toString(36).slice(2, 8)}`;
const q = value.toLowerCase();
const shown = items.map((item, i) => (q ? item.toLowerCase().includes(q) : i < limit));
const empty = value.length > 2 && !shown.some(Boolean);
const searchIcon = '<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" />';
---
<div
class:list={["vs-search", className]}
data-limit={limit}
data-labels={JSON.stringify({ results: l.results, oneResult: l.oneResult, emptyTitle: l.emptyTitle })}
{...rest}
>
<div class="card">
<div class="field">
<svg class="field-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" set:html={searchIcon} />
<input
class="input"
type="text"
role="combobox"
aria-label={l.ariaLabel}
aria-autocomplete="list"
aria-controls={`${uid}-list`}
aria-expanded={shown.some(Boolean) ? "true" : "false"}
autocomplete="off"
spellcheck="false"
placeholder={l.placeholder}
value={value}
data-input
/>
<button type="button" class="clear" aria-label={l.clear} hidden={!value} data-clear>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="empty" hidden={!empty} data-empty>
<span class="empty-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" set:html={searchIcon} />
</span>
<span class="empty-title">{l.emptyTitle}</span>
<span class="empty-hint">{l.emptyHint}</span>
</div>
<div class="results" hidden={empty} data-results>
<GlideMenu class="list" role="listbox" id={`${uid}-list`} aria-label={l.listLabel} highlightRadius="6px">
{items.map((item, i) => (
<div class="option" role="option" id={`${uid}-opt-${i}`} aria-selected="false" data-menu-row data-index={i} hidden={!shown[i]}>
{item}
</div>
))}
</GlideMenu>
</div>
</div>
<span class="vh" aria-live="polite" data-live></span>
</div>
<script>
function init(root: HTMLElement) {
const input = root.querySelector<HTMLInputElement>("[data-input]")!;
const clear = root.querySelector<HTMLButtonElement>("[data-clear]")!;
const empty = root.querySelector<HTMLElement>("[data-empty]")!;
const results = root.querySelector<HTMLElement>("[data-results]")!;
const list = root.querySelector<HTMLElement>("[role=listbox]")!;
const options = [...root.querySelectorAll<HTMLElement>("[role=option]")];
const live = root.querySelector<HTMLElement>("[data-live]")!;
const labels = JSON.parse(root.dataset.labels || "{}") as { results: string; oneResult: string; emptyTitle: string };
const limit = Number(root.dataset.limit ?? 5);
let visible: HTMLElement[] = options.filter((o) => !o.hidden);
let active = -1;
let announceTimer: number | undefined;
const setActive = (index: number, fromPointer = false) => {
active = index;
options.forEach((o) => {
const on = o === visible[index];
o.setAttribute("aria-selected", String(on));
o.toggleAttribute("data-active", on);
});
const option = visible[index];
if (option) {
input.setAttribute("aria-activedescendant", option.id);
if (!fromPointer) {
// Move the glide highlight with the keyboard (it follows mouseover).
option.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
option.scrollIntoView({ block: "nearest" });
}
} else {
input.removeAttribute("aria-activedescendant");
list.dispatchEvent(new MouseEvent("mouseleave"));
}
};
const update = () => {
const query = input.value;
const q = query.toLowerCase();
const before = new Set(visible);
visible = [];
options.forEach((o, i) => {
const show = q ? (o.textContent ?? "").trim().toLowerCase().includes(q) : i < limit;
o.hidden = !show;
if (!show) return;
visible.push(o);
// Rows that just appeared fade in.
if (!before.has(o)) {
o.removeAttribute("data-enter");
void o.offsetWidth;
o.setAttribute("data-enter", "");
}
});
const isEmpty = query.length > 2 && visible.length === 0;
empty.hidden = !isEmpty;
results.hidden = isEmpty;
clear.hidden = !query;
input.setAttribute("aria-expanded", String(visible.length > 0));
setActive(-1);
window.clearTimeout(announceTimer);
announceTimer = window.setTimeout(() => {
live.textContent =
visible.length === 0 ? labels.emptyTitle : visible.length === 1 ? labels.oneResult : labels.results.replace("{n}", String(visible.length));
}, 500);
root.dispatchEvent(new CustomEvent("vs-search-input", { bubbles: true, detail: { query, count: visible.length } }));
};
const choose = (option: HTMLElement) => {
const value = (option.textContent ?? "").trim();
input.value = value;
update();
input.focus();
root.dispatchEvent(new CustomEvent("vs-search-select", { bubbles: true, detail: { value, index: Number(option.dataset.index) } }));
};
input.addEventListener("input", update);
input.addEventListener("keydown", (event) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const n = visible.length;
if (event.key === "ArrowDown" && n) {
event.preventDefault();
setActive(active < 0 ? 0 : (active + 1) % n);
} else if (event.key === "ArrowUp" && n) {
event.preventDefault();
setActive(active <= 0 ? n - 1 : active - 1);
} else if (event.key === "Enter" && visible[active]) {
event.preventDefault();
choose(visible[active]!);
} else if (event.key === "Escape" && input.value) {
event.preventDefault();
input.value = "";
update();
}
});
// Keep focus in the field when picking with the pointer.
list.addEventListener("mousedown", (event) => event.preventDefault());
list.addEventListener("click", (event) => {
const option = (event.target as Element).closest<HTMLElement>("[role=option]");
if (option) choose(option);
});
list.addEventListener("mouseover", (event) => {
if (!event.isTrusted) return;
const option = (event.target as Element).closest<HTMLElement>("[role=option]");
if (option) setActive(visible.indexOf(option), true);
});
clear.addEventListener("click", () => {
input.value = "";
update();
input.focus();
});
document.addEventListener("astro:before-swap", () => window.clearTimeout(announceTimer), { once: true });
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-search:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-search {
display: flex;
width: 100%;
max-width: 288px;
min-height: 248px;
flex-direction: column;
align-items: stretch;
}
.vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.card {
width: 100%;
align-self: flex-start;
overflow: hidden;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-raised);
transition: box-shadow var(--vs-duration-fast) ease-out;
}
.card:has(.input:focus-visible) {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--vs-accent) 60%, transparent),
var(--vs-shadow-md);
}
/* ── field ── */
.field {
display: flex;
height: 40px;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--vs-line);
transition: background-color 100ms ease-out;
}
.field:hover {
background: var(--vs-hover);
}
.field-icon {
flex-shrink: 0;
color: var(--vs-ink-3);
}
.input {
min-width: 0;
flex: 1;
padding: 0;
border: none;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 13px;
outline: none;
}
.input::placeholder {
color: var(--vs-ink-3);
}
.clear {
display: flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--vs-radius-pill);
background: transparent;
color: var(--vs-ink-3);
cursor: pointer;
animation: vs-fade-in 150ms ease-out both;
transition:
background-color 100ms ease-out,
color 100ms ease-out;
}
.clear:hover {
background: color-mix(in srgb, var(--vs-line) 70%, transparent);
color: var(--vs-ink);
}
.clear:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.clear[hidden] {
display: none;
}
/* ── results ── */
.results {
padding: 4px;
}
.results[hidden] {
display: none;
}
.list {
display: flex;
flex-direction: column;
gap: 1px;
}
.option {
position: relative;
z-index: 1;
display: flex;
width: 100%;
height: 32px;
align-items: center;
padding: 0 8px;
border-radius: 6px;
color: var(--vs-ink);
font-size: 13px;
text-align: left;
cursor: pointer;
user-select: none;
}
.option[hidden] {
display: none;
}
.option[data-enter] {
animation: vs-fade-in 200ms ease-out both;
}
/* Keyboard-active row keeps a fill whenever the glide highlight is not on it. */
.list:not([data-glide-active]) .option[data-active] {
background: var(--vs-hover);
}
/* ── empty state ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 32px 16px;
animation: vs-fade-in 250ms ease-out both;
}
.empty[hidden] {
display: none;
}
.empty-icon {
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
margin-bottom: 6px;
border-radius: var(--vs-radius-control);
background: var(--vs-inset);
box-shadow: var(--vs-shadow-hairline);
color: var(--vs-ink-3);
}
.empty-title {
font-size: 13px;
font-weight: 500;
color: var(--vs-ink);
}
.empty-hint {
font-size: 12px;
color: var(--vs-ink-3);
}
@media (forced-colors: active) {
.option[data-active] {
outline: 2px solid Highlight;
}
}
</style> Ported from Beautiful UI, MIT. Notices.