Skip to main content
astrocraft-ui/ components · 101

Searchbox

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add overlays/searchbox

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add overlays/searchbox --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add overlays/searchbox --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/searchbox --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/searchbox --theme tailwind --bridge lumos

Live 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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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>
src/components/ui/overlays/searchbox/index.ts
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.