Searchbox
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add overlays/searchboxPlain-CSS theme — no build step
npx astrocraft-ui add overlays/searchbox --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add overlays/searchbox --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/searchbox --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/searchbox --theme tailwind --bridge lumosLive demo
Searchbox (Command Palette)
Press ⌘K / Ctrl K, or click the trigger to open.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Searchbox.astro` | `searchbox` `searchbox-empty` `searchbox-field` `searchbox-input` `searchbox-list` `searchbox-shortcut` `searchbox-trigger-label` | — | — | `[aria-expanded]` `[hidden]` |
| `SearchboxItem.astro` | `searchbox-item` | — | — | `[aria-selected]` |
Source
What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/overlays/searchbox/Searchbox.astro — headless primitive (see ../../README.md).
// Command palette: a trigger that opens the Dialog primitive (native <dialog>) holding a search input
// over a role="listbox" of <SearchboxItem>s. Typing filters items (substring match on their text);
// ↑/↓ move the active item via aria-activedescendant, Enter activates it (a link navigates, a button
// fires its handler), Esc closes (native). ⌘K / Ctrl+K opens it from anywhere. `id` must be unique
// (it is the dialog id). Items are static; remote/grouped results are out of scope.
//
// The empty state renders with the `hidden` ATTRIBUTE, never a class — `_listbox.ts` toggles the
// `hidden` property, so a class here could never be un-set. structure.css is what makes `hidden`
// beat a theme's `display` rule.
import type { HTMLAttributes } from "astro/types";
import { Dialog, DialogTrigger } from "../../overlays/dialog";
type Props = HTMLAttributes<"div"> & {
id: string;
label?: string;
placeholder?: string;
emptyText?: string;
shortcut?: string;
};
const {
id,
label = "Search…",
placeholder = "Type a command or search…",
emptyText = "No results found",
shortcut = "⌘K",
class: className,
...rest
} = Astro.props;
const listId = `${id}-list`;
---
<div data-slot="searchbox" class={className} {...rest}>
<DialogTrigger for={id} variant="outline">
<span data-slot="searchbox-trigger-label">
<slot name="trigger-icon"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg></slot
>
{label}
</span>
<kbd data-slot="searchbox-shortcut">{shortcut}</kbd>
</DialogTrigger>
<Dialog id={id} aria-label={label} data-variant="palette">
<div data-slot="searchbox-field">
<slot name="search-icon"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg></slot
>
<input
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
autocomplete="off"
placeholder={placeholder}
data-slot="searchbox-input"
/>
</div>
<div id={listId} role="listbox" data-slot="searchbox-list">
<slot />
<div data-slot="searchbox-empty" hidden>{emptyText}</div>
</div>
</Dialog>
</div>
<script>
import { createActiveDescendant, filterByText } from "../../_listbox";
import { onReadyOnce } from "../../_once";
let uid = 0;
let shortcutBound = false;
// ponytail: ⌘K opens the FIRST searchbox on the page — enough for the common one-palette-per-app
// case. For multiple palettes, key each off a distinct shortcut / data attribute instead.
function bindShortcut() {
if (shortcutBound) return;
shortcutBound = true;
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
const dialog = document.querySelector<HTMLDialogElement>(
'[data-slot="searchbox"] [data-slot="dialog"]',
);
if (dialog && !dialog.open) {
event.preventDefault();
dialog.showModal();
}
}
});
}
function wire(root: HTMLElement) {
const dialog = root.querySelector<HTMLDialogElement>('[data-slot="dialog"]');
const input = root.querySelector<HTMLInputElement>('[data-slot="searchbox-input"]');
const list = root.querySelector<HTMLElement>('[data-slot="searchbox-list"]');
const empty = root.querySelector<HTMLElement>('[data-slot="searchbox-empty"]');
if (!dialog || !input || !list) return;
const items = [...list.querySelectorAll<HTMLElement>('[data-slot="searchbox-item"]')];
items.forEach((it) => (it.id ||= `searchbox-item-${(uid += 1)}`));
const rover = createActiveDescendant(input, items);
const filter = () => {
filterByText(items, input.value, empty);
if (rover.active()?.hidden) rover.setActive(rover.visible()[0] ?? null);
};
input.addEventListener("input", () => {
filter();
rover.setActive(rover.visible()[0] ?? null);
});
input.addEventListener("keydown", (event) => {
const vis = rover.visible();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
rover.move(1);
break;
case "ArrowUp":
event.preventDefault();
rover.move(-1);
break;
case "Home":
event.preventDefault();
rover.setActive(vis[0] ?? null);
break;
case "End":
event.preventDefault();
rover.setActive(vis[vis.length - 1] ?? null);
break;
case "Enter": {
const a = rover.active();
if (a) {
event.preventDefault();
a.click();
}
break;
}
}
});
for (const it of items) {
it.addEventListener("mousemove", () => rover.setActive(it));
it.addEventListener("click", () => dialog.close());
}
// Native <dialog> focuses the first focusable on showModal (our input), so no open hook is needed;
// just clear the query on close so the next open starts fresh.
dialog.addEventListener("close", () => {
input.value = "";
filter();
rover.setActive(null);
});
}
bindShortcut();
onReadyOnce('[data-slot="searchbox"]', wire);
</script>
---
// src/components/ui/overlays/searchbox/SearchboxItem.astro — Searchbox compound part (see ../../README.md).
// One role="option" result. Renders <a> when `href` is set (Enter/click navigates), otherwise a
// <button> (wire your own click handler). The slotted content is what the palette filters on and what
// the active-item highlight and keyboard nav target.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { href?: string };
const { href, class: className, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---
<Tag
href={href}
type={href ? undefined : "button"}
role="option"
aria-selected="false"
tabindex="-1"
data-slot="searchbox-item"
class={className}
{...rest}
>
<slot />
</Tag>
import Searchbox from "./Searchbox.astro";
import SearchboxItem from "./SearchboxItem.astro";
export { Searchbox, SearchboxItem };
export default Searchbox;
What you get
The component source, copied into your project by npx astrocraft-ui add overlays/searchbox — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.