Search palette
NewA command search that filters as you type, with a gliding highlight and an empty state.
search-palette/search-palette.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Jumping to a command by name from the keyboard: type
fla, arrow down, Enter. - A list of up to a few dozen actions that is quicker to search than to scan.
- Places where the first few suggestions cover most visits: the idle list shows them before a key is pressed.
- Anywhere a search can come back empty and should say so, instead of leaving a blank card.
When not to use
- Filtering a table or a list already on the page. Use a filter field above it.
- Long questions in plain language. Those belong in an assistant’s composer.
- The only way to reach a feature. Keep a visible path to it too.
- Thousands of server-side results. It filters in memory; remote search needs loading and paging states.
You can add this component with the shadcn CLI or by hand:
Into components/search-palette of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/search-palette.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { SearchPalette } from './search-palette';const commands = [ 'Open recent files', 'Run the test suite', 'Summarize this thread', 'Draft a release note', 'Invite a teammate',];export function CommandSearch({ onRun }: { onRun: (command: string) => void }) { return ( <SearchPalette commands={commands} placeholder="Search commands…" label="Search commands" onSelect={onRun} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A search field over a short list of commands. Each letter narrows the list on the spot, a soft highlight glides under the row you point at or reach with the arrow keys, and a search that finds nothing says so instead of leaving a blank card.
- With the field empty, the first few commands show (five by default). A search runs over all of them, matching any part of a command’s words in either case, so “c” can bring up a sixth row the idle list leaves out.
- Rows are keyed by their words, so the rows a new letter keeps stay where they are and only the ones that come back fade in (200 ms, ease-out). The card’s height follows the list at once, with no tween, the way a native menu does.
- One highlight sits behind every row and glides to the active one by its top and height (220 ms on a fast-out curve, read from the row’s offsets). When the pointer leaves it fades out where it is (150 ms); the next time it shows it appears in place, with no slide in from the last row.
- A search of three letters or more that matches nothing swaps the list for an empty state: a magnifier in a small inset tile over a title and a hint, fading in over 250 ms. One or two letters that match nothing just show no rows, since that early nothing has really failed yet.
- A round clear button fades in (150 ms) as soon as there is something to clear. Picking a command, by click or Enter, puts its words in the field and calls onSelect.
- The field is a combobox over a listbox: the arrow keys move the highlight through aria-activedescendant, so focus never leaves the field; Enter picks, Escape clears.
- While on screen and untouched, a demo hovers a few rows, types “fla” to narrow the list, walks the two results, adds an “x” that finds nothing, then clears and starts again. It never takes focus, its searches are not announced, and any key or press hands the palette to the reader.
- Every colour is a light-dark() token on the root, so the palette follows the page’s color-scheme or the colorScheme prop. The card’s shadow is written once with all its layers, those a scheme does not use being transparent: a long soft lift by day, a short dark one at night.
| Prop | Type | Default | Description |
|---|---|---|---|
commands | string[] | seven sample commands | Everything there is to search, in the order it is listed. |
limit | number | 5 | How many commands show while the field is empty; a search looks through all of them. |
minEmptyLength | number | 3 | How long a search must be before finding nothing shows the empty state; shorter ones just show no rows. |
defaultQuery | string | — | Opens with these words in the field. Setting it turns the demo off. |
defaultActive | number | — | Opens with this row highlighted, by index. Setting it turns the demo off. |
onSelect | (command: string) => void | — | Called with the command picked, by click or Enter. |
onQueryChange | (query: string) => void | — | Called with the field’s words as the reader types, clears or picks. |
placeholder | string | 'Search flavors…' | The field’s placeholder. |
label | string | 'Search flavors' | The field’s and the list’s accessible name. |
emptyTitle | string | 'No results found' | The empty state’s title, also announced. |
emptyHint | string | 'Adjust your search to try again' | The quieter line under it. |
clearLabel | string | 'Clear search' | The clear button’s accessible name. |
autoplay | boolean | true | While on screen and untouched, a demo hovers, searches, finds nothing and clears by itself. Off under reduced motion. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Added to the root, which fills the width it is given. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './search-palette.module.css';/* * Search palette: a search field over a short list of commands. Typing filters the list as you * go; a search that finds nothing says so. * * - With the field empty the first few commands show (five by default); a search runs over all of * them, any part of a command's words, upper or lower case alike. * - Rows are keyed by their words, so the rows a new letter keeps stay still and only the ones * that come back fade in (200 ms). The card's height follows at once, with no tween./* Search palette (search-palette.tsx): a soft grey stage with the card at the top of its middle, 288 px at most, or at night a graphite stage with the card a step lighter. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The card's shadow is one list for both schemes, each layer coloured for one of them and clear in the other. Motion is opacity, plus the highlight's top and height within one short list. */.root { /* The stage, the card, the highlight and the empty state's tile. */ --canvas: oklch(96.1% 0.002 247.84); --surface: oklch(100% 0 0); --hover: oklch(97% 0.002 247.839); --inset: oklch(97.9% 0.002 247.839); /* Words: strong, and quiet (placeholder, icons, the hint). */ --ink: oklch(24.7% 0.006 258.361); --ink-3: oklch(69.5% 0.009 264.505); /* Hairlines: the stage's edge, the field's underline, the tile's ring. */ --line: oklch(94.6% 0.003 264.542); /* The card's ring and lift: the day's long soft shadows, the night's short dark one. */ --card-ring: oklch(94.6% 0.003 264.542); --day-4: rgb(0 0 0 / 0.04);'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The field is a real input with role="combobox", aria-expanded, aria-controls and aria-autocomplete="list"; the results are a listbox of options, and the highlighted one is named by aria-activedescendant, so focus stays in the field.
- ArrowDown and ArrowUp move the highlight (wrapping at the ends), Enter picks the highlighted command, Escape clears the search.
- A polite status line counts the results as the reader types (“2 results”), says “No results found” for an empty search, and names the command picked. The demo’s searches are not announced.
- The clear button is a real button with its own name, shows a focus ring on keyboard focus, and puts focus back in the field.
- Icons are drawn in SVG and hidden from assistive technology; the highlight is a decorative layer.
- Under prefers-reduced-motion the demo does not run, rows and the empty state appear without fading and the highlight moves without gliding.