No results found Adjust your search to try again
Astro UI
Search.
Command search with live filtering and an empty state.
NavigationComponent1 dependency Beautiful UI, MIT
Install
-
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.