Terracotta
GitHub

Wrap it in CommandBar for the familiar Cmd/Ctrl+K modal, or drop it straight into a sidebar or search page.

Navigation is virtual, as in Combobox. Focus stays on the input, and the active option is published through aria-activedescendant.

import {
  Command,
  CommandLabel,
  CommandInput,
  CommandOptions,
  CommandOption,
} from 'terracotta/command';
import {
  AutocompleteStateChild,
  useAutocompleteState,
} from 'terracotta/states';

Anatomy

<Command matchBy>{/* owns the query and the selection */}
  <CommandLabel/>{/* names the input and the list */}
  <CommandInput/>{/* role="combobox", drives the query */}
  <CommandOptions>{/* role="listbox", always visible */}
    <CommandOption/>
  </CommandOptions>
</Command>

Filtering is yours to render

matchBy decides which options match. Terracotta marks those with tc-matches and leaves every option in the DOM. Hide the rest yourself:

.command-option:not([tc-matches]) { display: none; }

Either way, keyboard navigation only visits matching, non-disabled options.

Examples

A palette of actions

Running the action from onChange is the usual pattern. Command tracks a selected value, and what you do with it is up to you.

Entries with icons and shortcut hints

<CommandOption class="command-option command-option-rich" value={action}>
  <span class="command-icon" aria-hidden="true">{action.icon}</span>
  <span class="command-label">{action.label}</span>
  <span class="command-shortcut" aria-hidden="true">{action.shortcut}</span>
</CommandOption>
.command-option-rich {
  display: grid;
  grid-template-columns: 1.25rem 1fr auto;
  align-items: center;
  gap: 0.625rem;
}

.command-shortcut {
  color: #a1a1aa;
  font-size: 0.8125rem;
}

An empty state

The list's render prop receives the autocomplete state:

<CommandOptions class="command-options">
  {state => (
    <>
      <For each={props.actions}>
        {action => (
          <CommandOption class="command-option" value={action}>
            {action.label}
          </CommandOption>
        )}
      </For>
      <Show when={state.hasQuery() && !props.actions.some(a => state.matches(a))}>
        <li class="command-empty">No commands match “{state.query()}”</li>
      </Show>
    </>
  )}
</CommandOptions>
.command-empty {
  padding: 1rem;
  color: #71717a;
  text-align: center;
}

Grouped entries

Navigation ignores non-option elements, so headings need no special handling. To hide a heading when none of its entries match, combine tc-has-query with your own logic:

<CommandOptions class="command-options">
  <li class="command-group" role="presentation">Navigation</li>
  <For each={navigationActions}>
    {action => (
      <CommandOption class="command-option" value={action}>{action.label}</CommandOption>
    )}
  </For>
  <li class="command-group" role="presentation">Editing</li>
  <For each={editingActions}>
    {action => (
      <CommandOption class="command-option" value={action}>{action.label}</CommandOption>
    )}
  </For>
</CommandOptions>
.command-group {
  padding: 0.5rem 0.75rem 0.25rem;
  color: #71717a;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

Multiple selection

Use this for a filter palette rather than an action palette:

const [active, setActive] = createSignal<Filter[]>([]);

<Command<Filter>
  class="command"
  multiple
  toggleable
  value={active()}
  onChange={setActive}
  by={(a, b) => a.id === b.id}
  matchBy={(filter, query) => filter.label.toLowerCase().includes(query.toLowerCase())}
>
  <CommandInput class="command-input" placeholder="Filter…" />
  <CommandOptions class="command-options">
    <For each={filters}>
      {filter => (
        <CommandOption class="command-option command-option-check" value={filter}>
          <span class="command-mark" aria-hidden="true" />
          {filter.label}
        </CommandOption>
      )}
    </For>
  </CommandOptions>
</Command>
.command-option-check {
  display: grid;
  grid-template-columns: 1rem 1fr;
  align-items: center;
  gap: 0.5rem;
}

.command-option[tc-selected] .command-mark::before {
  content: "✓";
  color: #1d4ed8;
}

Showing a clear button only while typing

<div class="command-field">
  <CommandInput class="command-input" placeholder="Type a command…" />
  <button type="button" class="command-clear" onClick={reset}>Clear</button>
</div>
.command-field { position: relative; }

.command-clear {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: 0.5rem;
  translate: 0 -50%;
}

/* tc-has-query lives on the input, and on the Command root */
.command:not([tc-has-query]) .command-clear { display: none; }

Disabled entries

<CommandOption class="command-option" value={action} disabled={!action.available}>
  {action.label}
</CommandOption>

The arrow keys skip disabled entries, even when they match the query.

Reading the query

function QueryEcho(): JSX.Element {
  const state = useAutocompleteState<Action>();
  return <Show when={state.hasQuery()}><p class="hint">“{state.query()}”</p></Show>;
}

<AutocompleteStateChild> does the same inline. Note that query() runs 250 ms behind what has been typed, because it is debounced.

State attributes

Element Attribute Present when
Command tc-command Always
Command tc-has-selected Something is selected
Command tc-has-active An entry is virtually focused
Command tc-has-query The query is non-empty
Command tc-disabled The palette is disabled
CommandLabel tc-command-label Always
CommandLabel tc-has-selected, tc-has-active, tc-has-query, tc-disabled Mirrors the palette
CommandInput tc-command-input Always
CommandInput tc-has-selected, tc-has-active, tc-has-query, tc-disabled Mirrors the palette
CommandOptions tc-command-options Always
CommandOptions tc-has-selected, tc-has-active, tc-has-query, tc-disabled Mirrors the palette
CommandOption tc-command-option, tc-button, tc-owner Always
CommandOption tc-matches This entry matches the current query
CommandOption tc-selected This entry is selected
CommandOption tc-active This entry is virtually focused
CommandOption tc-disabled This entry or the palette is disabled

There is no tc-expanded here. The list is always visible, and for the same reason CommandInput reports a constant aria-expanded="true".

Styling

/* Filtering */
[tc-command-option]:not([tc-matches]) { display: none; }

/* Virtual focus: the only highlight, since DOM focus stays on the input */
[tc-command-option][tc-active]   { background: #f4f4f5; }
[tc-command-option][tc-selected] { font-weight: 600; }
[tc-command-option][tc-disabled] { color: #a1a1aa; }

/* Container-level query state */
[tc-command]:not([tc-has-query]) .command-clear { display: none; }
[tc-command][tc-has-query] .command-recent { display: none; }

/* Nothing highlighted yet */
[tc-command]:not([tc-has-active]) .command-hint { display: block; }

Reading the state in code

The autocomplete state. Reach it through the render prop on Command or CommandOptions, through <AutocompleteStateChild>, or with useAutocompleteState():

Member Type Description
query() string The current query, debounced by 250 ms.
setQuery(value) (string) => void Sets the query.
hasQuery() boolean Whether the query is non-empty.
matches(value) (V) => boolean Runs matchBy against the current query.
isSelected(value) / hasSelected() Selection.
isActive(value) / hasActive() Virtual focus position.
select(value) / focus(value) / blur() Changes them.
disabled() boolean Whether the palette is disabled.

Inside an entry, useAutocompleteOptionState() gives the per-option view: isSelected(), isActive(), matches(), select() and disabled().

Keyboard

Handled on CommandInput:

Key Action
Typing Updates the query (debounced 250 ms) and moves to the first match
↓ / ↑ Next / previous matching entry, wrapping around
Enter Selects the active entry

Focusing the input restores the entry that was active before. Failing that, it jumps to the selected entry, or to the first match. Hovering an entry makes it active, so mouse and keyboard agree on what is highlighted.

Command handles no Escape of its own. That belongs to whatever wraps it, usually CommandBar.

API

<Command>

Owns an autocomplete state and renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
matchBy (value: V, query: string) => boolean required Decides whether an entry matches the current query.
multiple boolean false When true, value / defaultValue / onChange deal in arrays.
defaultValue V | V[] none Initial selection, uncontrolled. Mutually exclusive with value.
value V | V[] none Current selection, controlled. Mutually exclusive with defaultValue.
onChange (value?: V) => void | (value: V[]) => void none Called with the new selection.
toggleable boolean false Selecting the already-selected value clears or removes it.
disabled boolean false Disables the whole palette.
by (a: V, b: V) => boolean reference equality Compares values. Needed for object values.
horizontal boolean false Accepted for symmetry with Select, but currently unused: the list is always navigated vertically.
children JSX.Element | (state: AutocompleteStateProperties<V>) => JSX.Element none The label, input and options, or a render prop.
…rest props of as none Forwarded to the rendered element.

<CommandLabel>

Names the input and the list through aria-labelledby. Both drop the attribute when no CommandLabel is rendered, so name them yourself with aria-label in that case. Renders a <label> by default. It takes no ref, and its children is not a render prop.

Prop Type Default Description
as ValidConstructor 'label' Element or component to render as.
children JSX.Element none Label text.
…rest props of as none Forwarded to the rendered element.

<CommandInput>

The text field. Renders an <input type="text"> by default.

Prop Type Default Description
as ValidConstructor 'input' Element or component to render as. Query tracking only works on a real <input>.
disabled boolean false Disables the input's own handling. It is also disabled when the Command is.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
…rest props of as none Forwarded to the rendered element, including placeholder and value.

Rendered attributes include role="combobox", type="text", tabindex="0", aria-autocomplete="list", aria-controls, aria-labelledby, aria-expanded="true" and aria-activedescendant.

<CommandOptions>

The list. Renders a <ul> by default, always mounted, and kept out of the tab order.

Prop Type Default Description
as ValidConstructor 'ul' Element or component to render as.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: AutocompleteStateProperties<V>) => JSX.Element none The entries, or a render prop receiving the autocomplete state.
…rest props of as none Forwarded to the rendered element.

Rendered attributes include role="listbox", aria-multiselectable, aria-labelledby, aria-orientation="vertical" and tabindex="-1".

<CommandOption>

One entry. A Button with role="option", rendered as an <li> by default.

Prop Type Default Description
as ValidConstructor 'li' Element or component to render as.
value V required The value this entry represents; also what matchBy receives.
disabled boolean false Disables this entry and removes it from navigation.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: AutocompleteOptionStateProperties) => JSX.Element none Label, or a render prop.
…rest props of as none Forwarded to the rendered element.

Rendered attributes include role="option", aria-selected, tabindex="-1", and a generated id, which is the one aria-activedescendant points at.

Every descendant throws if rendered outside a <Command>.