# Search palette

> A command search that filters as you type, with a gliding highlight and an empty state.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/search-palette. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Card**: One surface for the field and the list. Its height follows the list at once, with no tween, the way a native menu does.
2. **Search field**: A combobox: the arrow keys move the highlight while focus stays here. Escape clears.
3. **Lens**: Says what the field is for before a word is typed.
4. **Clear button**: Fades in once there is something to clear, and puts focus back in the field.
5. **Highlight**: Exactly one, under the row the pointer or the arrow keys are on. It glides by top and height in 220 ms.
6. **Command row**: The command’s words, matched anywhere in either case. Rows a new letter keeps stay still.

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

## Installation

Install it with the shadcn CLI, into components/search-palette of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/search-palette.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
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}
    />
  );
}
```

## States

- **Default**: Nothing typed: the first five commands, so the common ones are one arrow away.
- **Pointed at**: The highlight appears in place under the row, with no slide in from elsewhere.
- **Typing**: Each letter narrows the list; the clear button is in.
- **Highlighted**: Arrow keys walk the results; Enter picks the highlighted one.
- **Too early to say**: One or two letters that match nothing just show no rows: nothing has failed yet.
- **No results**: Three letters or more and still nothing: a magnifier tile, a title and a way out.
- **Picked**: The command’s words go in the field and `onSelect` is called with them.

## How it works

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

## API reference

- **commands** (`string[]`, default `seven sample commands`): Everything there is to search, in the order it is listed.
- **limit** (`number`, default `5`): How many commands show while the field is empty; a search looks through all of them.
- **minEmptyLength** (`number`, default `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`, default `'Search flavors…'`): The field’s placeholder.
- **label** (`string`, default `'Search flavors'`): The field’s and the list’s accessible name.
- **emptyTitle** (`string`, default `'No results found'`): The empty state’s title, also announced.
- **emptyHint** (`string`, default `'Adjust your search to try again'`): The quieter line under it.
- **clearLabel** (`string`, default `'Clear search'`): The clear button’s accessible name.
- **autoplay** (`boolean`, default `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.

## Accessibility

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

## Source

### search-palette/search-palette.tsx

```tsx
// 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.
 * - A soft highlight sits under the row the pointer (or the arrow keys) is on and glides to the
 *   next by its top and height (220 ms on a fast-out curve); it fades away where it is when the
 *   pointer leaves, and the next time it shows it appears in place instead of sliding in.
 * - A search of three letters or more that finds nothing swaps the list for an empty state, a
 *   magnifier in a small tile over two lines, fading in over 250 ms. Shorter searches just show
 *   no rows: one or two letters are too early to say nothing matches.
 * - A clear button fades in (150 ms) once there is something to clear. Picking a command puts its
 *   words in the field and calls onSelect.
 * - The field is a combobox: arrow keys move the highlight, Enter picks, Escape clears. Results
 *   are counted for screen readers.
 * - While on screen and untouched, a demo plays it by itself: it hovers a few rows, types a
 *   search that narrows the list, walks it, adds a letter that finds nothing, then clears. Any
 *   real input hands it to the reader.
 * - defaultQuery and defaultActive open it on a search and a highlighted row, with no demo: for
 *   documentation and tests.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

const COMMANDS = [
  'Forecast summer demand',
  'Find waffle cone suppliers',
  'Compare seasonal flavors',
  'Draft flavor launch plan',
  'Check cold-chain status',
  'Audit sugar costs',
  'Retire low sellers',
];

type Glide = { top: number; height: number; on: boolean; snap: boolean };

const sleepy = (alive: () => boolean) => {
  let timer = 0;
  const wait = (ms: number) =>
    new Promise<void>((resolve, reject) => {
      timer = window.setTimeout(() => (alive() ? resolve() : reject(new Error('stopped'))), ms);
    });
  return { wait, stop: () => window.clearTimeout(timer) };
};

export function SearchPalette({
  commands = COMMANDS,
  limit = 5,
  minEmptyLength = 3,
  onSelect,
  onQueryChange,
  placeholder = 'Search flavors…',
  label = 'Search flavors',
  emptyTitle = 'No results found',
  emptyHint = 'Adjust your search to try again',
  clearLabel = 'Clear search',
  defaultQuery,
  defaultActive,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Everything there is to search, in the order it is listed. */
  commands?: string[];
  /** How many commands show while the field is empty. */
  limit?: number;
  /** A search this long that finds nothing shows the empty state; shorter ones show no rows. */
  minEmptyLength?: number;
  /** Called with the command picked (by click or Enter). */
  onSelect?: (command: string) => void;
  /** Called with the field's words as the reader types. */
  onQueryChange?: (query: string) => void;
  placeholder?: string;
  /** The field's accessible name. */
  label?: string;
  emptyTitle?: string;
  emptyHint?: string;
  clearLabel?: string;
  /** Opens with these words in the field (and no demo), for documentation and tests. */
  defaultQuery?: string;
  /** Opens with this row highlighted (and no demo), for documentation and tests. */
  defaultActive?: number;
  /** While on screen and untouched, a demo searches by itself. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [query, setQuery] = useState(defaultQuery ?? '');
  const [active, setActive] = useState<number | null>(defaultActive ?? null);
  const [glide, setGlide] = useState<Glide>({ top: 0, height: 0, on: false, snap: true });
  const [message, setMessage] = useState('');
  const input = useRef<HTMLInputElement>(null);
  const rows = useRef<(HTMLDivElement | null)[]>([]);
  const uid = useId();
  const listId = `${uid}-list`;

  const words = query.trim().toLowerCase();
  const results = words
    ? commands.filter((command) => command.toLowerCase().includes(words))
    : commands.slice(0, limit);
  const empty = results.length === 0 && words.length >= minEmptyLength;
  const demo = autoplay && playing && defaultQuery === undefined && defaultActive === undefined;

  // The highlight follows the active row; from hidden it appears in place, then glides.
  useLayoutEffect(() => {
    const row = active === null ? null : rows.current[active];
    setGlide((now) =>
      row
        ? { top: row.offsetTop, height: row.offsetHeight, on: true, snap: !now.on }
        : { ...now, on: false, snap: false },
    );
  }, [active, query]);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const search = (next: string, quiet = false) => {
    setQuery(next);
    setActive(null);
    if (quiet) return;
    onQueryChange?.(next);
    const found = next.trim().toLowerCase();
    if (!found) return setMessage('');
    const count = commands.filter((command) => command.toLowerCase().includes(found)).length;
    if (count) announce(`${count} result${count === 1 ? '' : 's'}`);
    else if (found.length >= minEmptyLength) announce(emptyTitle);
  };

  const pick = (command: string) => {
    setQuery(command);
    setActive(null);
    onSelect?.(command);
    onQueryChange?.(command);
    announce(command);
  };

  const clear = () => {
    search('');
    input.current?.focus();
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      if (!results.length) return;
      const step = event.key === 'ArrowDown' ? 1 : -1;
      setActive((now) =>
        now === null
          ? step > 0
            ? 0
            : results.length - 1
          : (now + step + results.length) % results.length,
      );
    } else if (event.key === 'Enter' && active !== null && results[active]) {
      event.preventDefault();
      pick(results[active]);
    } else if (event.key === 'Escape' && query) {
      event.preventDefault();
      search('');
    }
  };

  // The demo: hover a few rows, narrow the list, walk it, find nothing, clear; then again.
  const demoSearch = useEffectEvent((next: string) => search(next, true));
  const demoHover = useEffectEvent((row: number | null) => setActive(row));

  useEffect(() => {
    if (!demo) return;
    let alive = true;
    const { wait, stop } = sleepy(() => alive);
    const type = async (from: string, text: string) => {
      for (let i = 1; i <= text.length; i++) {
        demoSearch(from + text.slice(0, i));
        await wait(110 + Math.random() * 70);
      }
    };
    const run = async () => {
      demoSearch('');
      await wait(900);
      for (;;) {
        for (const row of [0, 1, 3]) {
          demoHover(row);
          await wait(480);
        }
        demoHover(null);
        await wait(600);
        await type('', 'fla');
        await wait(650);
        for (const row of [0, 1]) {
          demoHover(row);
          await wait(520);
        }
        demoHover(null);
        await wait(400);
        await type('fla', 'x');
        await wait(1800);
        demoSearch('fla');
        await wait(700);
        demoSearch('');
        await wait(1500);
      }
    };
    run().catch(() => {});
    return () => {
      alive = false;
      stop();
      demoHover(null);
    };
  }, [demo]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.column}>
        <div className={styles.card} data-part="card">
          <div className={styles.field} data-part="field">
            <svg className={styles.lens} data-part="lens" viewBox="0 0 24 24" aria-hidden="true">
              <circle cx="11" cy="11" r="7" />
              <path d="M16.6 16.6 21 21" />
            </svg>
            <input
              ref={input}
              className={styles.input}
              type="text"
              role="combobox"
              aria-label={label}
              aria-expanded={!empty && results.length > 0}
              aria-controls={listId}
              aria-autocomplete="list"
              aria-activedescendant={active === null ? undefined : `${uid}-${active}`}
              placeholder={placeholder}
              autoComplete="off"
              spellCheck={false}
              value={query}
              onChange={(event) => search(event.target.value)}
              onKeyDown={onKeyDown}
            />
            {query && (
              <button
                type="button"
                className={styles.clear}
                data-part="clear"
                aria-label={clearLabel}
                onClick={clear}
              >
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M6.2 6.2 17.8 17.8M17.8 6.2 6.2 17.8" />
                </svg>
              </button>
            )}
          </div>
          {empty ? (
            <div className={styles.empty} data-part="empty">
              <span className={styles.tile} data-part="empty-tile" aria-hidden="true">
                <svg viewBox="0 0 24 24">
                  <circle cx="11" cy="11" r="7" />
                  <path d="M16.6 16.6 21 21" />
                </svg>
              </span>
              <span className={styles.title} data-part="empty-title">
                {emptyTitle}
              </span>
              <span className={styles.hint}>{emptyHint}</span>
            </div>
          ) : (
            <div className={styles.body}>
              <div
                id={listId}
                className={styles.list}
                role="listbox"
                aria-label={label}
                onPointerMove={take}
                onPointerLeave={() => setActive(null)}
              >
                <span
                  aria-hidden="true"
                  className={styles.glide}
                  data-part="highlight"
                  data-on={glide.on}
                  data-snap={glide.snap}
                  style={{ top: glide.top, height: glide.height }}
                />
                {results.map((command, i) => (
                  <div
                    key={command}
                    id={`${uid}-${i}`}
                    ref={(element) => {
                      rows.current[i] = element;
                    }}
                    className={styles.row}
                    data-part="row"
                    role="option"
                    aria-selected={active === i}
                    onPointerEnter={() => setActive(i)}
                    onClick={() => pick(command)}
                  >
                    {command}
                  </div>
                ))}
              </div>
            </div>
          )}
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### search-palette/search-palette.module.css

```css
/* 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);
  --day-3: rgb(0 0 0 / 0.03);
  --day-2: rgb(0 0 0 / 0.02);
  --day-1: rgb(0 0 0 / 0.01);
  --night: transparent;
  /* The same on both schemes: the focus ring. */
  --focus: oklch(62.6% 0.205 254.947);
  position: relative;
  container-type: inline-size;
  display: flex;
  justify-content: center;
  align-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --card-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.13));
    --day-4: light-dark(rgb(0 0 0 / 0.04), transparent);
    --day-3: light-dark(rgb(0 0 0 / 0.03), transparent);
    --day-2: light-dark(rgb(0 0 0 / 0.02), transparent);
    --day-1: light-dark(rgb(0 0 0 / 0.01), transparent);
    --night: light-dark(transparent, oklch(0% 0 0 / 0.22));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) :where(button, input) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
}
:where(.root) :where(button) {
  cursor: pointer;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The card stands at the top of a column 248 px tall, so the stage keeps its height while the
   list grows and shrinks. */
.column {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 288px;
  min-height: 248px;
}

.card {
  overflow: hidden;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-ring),
    0 17.54px 23.39px 0 var(--day-4),
    0 9.4px 12.5px 0 var(--day-3),
    0 5.25px 7px 0 var(--day-2),
    0 2.79px 3.72px -2px var(--day-1),
    0 1.16px 1.5px 0 var(--day-1),
    0 2px 10px 0 var(--night);
}

/* The field: a magnifier, the input and, once there is something to clear, the clear button. */
.field {
  display: flex;
  align-items: center;
  gap: 8px;
  box-sizing: border-box;
  height: 40px;
  padding: 0 12px;
  border-bottom: 1px solid var(--line);
  transition: background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.field:hover {
  background: var(--hover);
}

.lens {
  flex: none;
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--ink-3);
  stroke-width: 2;
  stroke-linecap: round;
}

.input {
  flex: 1;
  min-width: 0;
  height: 19.5px;
  outline: none;
  font-size: 13px;
  line-height: 19.5px;
}
.input::placeholder {
  color: var(--ink-3);
  opacity: 1;
}
.input::-webkit-search-cancel-button {
  display: none;
}

.clear {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 999px;
  color: var(--ink-3);
  animation: fade 150ms ease-out both;
  transition:
    color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.clear:hover {
  background: color-mix(in oklab, var(--line) 70%, transparent);
  color: var(--ink);
}
.clear svg {
  width: 11px;
  height: 11px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
}

/* The list: rows 32 px tall, 1 px apart, over a highlight that glides between them. */
.body {
  padding: 4px;
}
.list {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.glide {
  position: absolute;
  inset-inline: 0;
  border-radius: 6px;
  background: var(--hover);
  opacity: 0;
  pointer-events: none;
  transition:
    top 220ms cubic-bezier(0.23, 1, 0.32, 1),
    height 220ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 150ms ease;
}
.glide[data-on='true'] {
  opacity: 1;
}
/* Shown from hidden: it appears where it is needed instead of sliding in. */
.glide[data-snap='true'] {
  transition: opacity 150ms ease;
}

.row {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  height: 32px;
  padding: 0 8px;
  overflow: hidden;
  border-radius: 6px;
  font-size: 13px;
  line-height: 19.5px;
  white-space: nowrap;
  text-overflow: ellipsis;
  cursor: pointer;
  user-select: none;
  animation: fade 200ms ease-out both;
}

/* Nothing found: a magnifier in a small tile over a title and a hint. */
.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 32px 16px;
  text-align: center;
  animation: fade 250ms ease-out both;
}
.tile {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  margin-bottom: 6px;
  border-radius: 8px;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink-3);
}
.tile svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}
.title {
  font-size: 13px;
  font-weight: 500;
  line-height: 19.5px;
}
.hint {
  color: var(--ink-3);
  font-size: 12px;
  line-height: 18px;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes fade {
  from {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .clear,
  .row,
  .empty {
    animation: none;
  }
  .glide,
  .glide[data-snap='true'] {
    transition: none;
  }
}
```

### search-palette/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```