---
title: Command Palette
description: Search over everything the host can name, in a native dialog: substring filter, arrow keys, and the host keeps the router.
source: https://adamjurek.com/components/command-palette
---

## Home


Command Palette is a search dialog over anything the host can name: type to
filter by substring, use arrow keys to move the selection, and press Enter to
choose. It wraps a native `<dialog>`, so focus trapping, Escape and the top
layer come from the browser, not from this code.

<!-- ::start:showcase demo="command-palette" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

A native `<dialog>` does the heavy lifting - `showModal` gives focus trapping,
Escape-to-close and a real top layer, none of which need reimplementing. What
this adds is the part dialogs do not have: a filter over everything the host
can name, and arrow-key selection over the result. Matching is plain substring
rather than fuzzy, because a palette whose first hit reorders as you type is
slower to use than one that is merely literal. The host owns the data and the
navigation - entries come in as props, the choice goes out through `onSelect`
- which is what keeps this installable in a project with any router.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/command-palette.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/command-palette.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | docs · Navigation |
| Files | `command-palette.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | search, keyboard, dialog, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useState } from "react";
import { CommandPalette } from "@sushindustries/ui";

const entries = [
	{ id: "button", title: "Button", href: "/components/button", group: "Components" },
	{ id: "card", title: "Card", href: "/components/card", group: "Components" },
];

export function Example() {
	const [open, setOpen] = useState(false);

	return (
		<CommandPalette
			entries={entries}
			open={open}
			onClose={() => setOpen(false)}
			onSelect={(entry) => {
				setOpen(false);
				window.location.href = entry.href;
			}}
		/>
	);
}
```

## What you should see

Nothing, until `open` is `true` - then a centred dialog with a search
field, autofocused, and both entries listed below it. Typing filters the
list by substring across title, hint and group; arrow keys move the
highlighted row, and Enter or a click calls `onSelect` with that entry.
Escape or a click on the backdrop calls `onClose`.

## If nothing happens

If the dialog never appears, check `open` actually flips to `true` -
`CommandPalette` always renders a `<dialog>` element in the DOM, but it
stays closed until the effect watching `open` calls `showModal()`.
Selecting an entry and nothing navigating means `onSelect` is set but not
actually changing the URL - the component only reports the choice, it
never routes on its own.


## Guides


## The host owns the shortcut and the routing

Nothing here listens for a keyboard shortcut to open itself - the host
wires whatever key (`⌘K`, `/`) to flipping `open`, and wires `onSelect`
to its own router. That split is deliberate: a palette that owns the
shortcut cannot be muted while a text field elsewhere on the page has
focus, and a palette that owns routing cannot be dropped into a project
with a different router.

## Matching is substring, not fuzzy

Typing "crd" will not find "Card" - matching is a plain substring over
`title`, `hint` and `group`, not scored fuzzy matching. That trade is on
purpose: fuzzy scoring reorders results as you type, and a first hit that
moves around is slower to use than one that is merely literal.

Only the first twelve matches render regardless of how many `entries`
match, so a long list depends on `hint` or `group` text narrowing things
down rather than scrolling.

## When not to use it

For a handful of destinations that fit in a normal nav menu, a palette
adds a keyboard-driven search surface nobody asked for - it earns its
place once there are enough entries that scanning a menu is slower than
typing a few letters.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `entries` | `readonly PaletteEntry[]` | - | Everything searchable. Matched by substring, and only the first twelve hits are shown. |
| `open` | `boolean` | - | Drives `showModal`. Opening clears the query and puts the selection back on the first hit. |
| `onClose` | `() => void` | - | Escape, the backdrop and the close event all arrive here. The host still owns `open`. |
| `onSelect` | `(entry: PaletteEntry) => void` | - | Called with the chosen entry; the host owns navigation. |
| `placeholder?` | `string` | `"Search"` | The only hint at what is searchable - nothing else labels the field. |

<!-- /generated:api -->

## Notes

`entries` should be a stable list - the component reads `id` for React's
key and for `aria-activedescendant`, so two entries sharing an `id`
collide silently and only one behaves correctly. Matching happens
against `title`, `hint` and `group` together; an entry with a
distinctive `hint` is reachable even when its `title` alone would not
match what somebody typed.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="command-palette" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useEffect, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { CommandPalette } from "@sushindustries/ui";
import { searchEntries } from "./search.catalogue";

export function SiteChrome({ children }: { children: React.ReactNode }) {
	const [open, setOpen] = useState(false);
	const navigate = useNavigate();

	useEffect(() => {
		function onKeyDown(event: KeyboardEvent) {
			if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
				event.preventDefault();
				setOpen(true);
			}
		}
		window.addEventListener("keydown", onKeyDown);
		return () => window.removeEventListener("keydown", onKeyDown);
	}, []);

	return (
		<>
			{children}
			<CommandPalette
				entries={searchEntries}
				open={open}
				onClose={() => setOpen(false)}
				onSelect={(entry) => {
					setOpen(false);
					navigate({ to: entry.href });
				}}
			/>
		</>
	);
}
```

## What this example is not

The `⌘K` listener here is the host's own effect, not something
`CommandPalette` sets up for you - the component only reacts to `open`.
`navigate` from the router replaces the plain `window.location` write
from Get Started, which is the difference between a full page load and a
client-side transition.
