Skip to content

Command

Command turns ordinary application records into one searchable, keyboard-complete command surface. Give it commands; it renders the real input, groups and options, keeps focus stable, filters titles and keywords, and gives the selected record back unchanged.

There is one component to install and use. Routes, icons, permissions, async work, nested flows, and product-specific fields remain on your records and in your application code.

Command.vue
Search “metrics”, use Arrow Up/Down, Home/End, and Enter, or point at an enabled result. The real input keeps focus throughout.

Installation

One command detects Vue, React, or Svelte and copies the matching framework-native file:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add command

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

That is the whole component. There is no initializer, config file, provider, generated class helper, runtime package, or interaction library.

Usage

Vue

ApplicationCommands.vue
<script setup>
import { ref } from 'vue'
import Command from '@/components/ui/command/Command.vue'

const query = ref('')
const commands = [
  {
    id: 'projects',
    title: 'Open projects',
    subtitle: 'View every application and service',
    keywords: ['apps'],
    group: 'Navigation',
    shortcut: 'G P',
    href: '/projects'
  },
  {
    id: 'deploy',
    title: 'Deploy application',
    keywords: ['ship', 'release'],
    group: 'Actions',
    shortcut: 'D',
    action: () => console.log('Choose an application')
  }
]

function run(command) {
  if (command.href) window.location.assign(command.href)
  else command.action?.()
}
</script>

<template>
  <Command
    v-model:query="query"
    :commands="commands"
    label="Application commands"
    @select="run"
  />
</template>

React

ApplicationCommands.jsx
import { useState } from 'react'
import Command from '@/components/ui/command/Command'

const commands = [
  {
    id: 'projects',
    title: 'Open projects',
    subtitle: 'View every application and service',
    keywords: ['apps'],
    group: 'Navigation',
    shortcut: 'G P',
    href: '/projects'
  },
  {
    id: 'deploy',
    title: 'Deploy application',
    keywords: ['ship', 'release'],
    group: 'Actions',
    shortcut: 'D',
    action: () => console.log('Choose an application')
  }
]

export default function ApplicationCommands() {
  const [query, setQuery] = useState('')

  function run(command) {
    if (command.href) window.location.assign(command.href)
    else command.action?.()
  }

  return (
    <Command
      query={query}
      commands={commands}
      label="Application commands"
      onQueryChange={setQuery}
      onSelect={run}
    />
  )
}

Svelte

ApplicationCommands.svelte
<script>
  import Command from '$lib/components/ui/command/Command.svelte'

  let query = $state('')
  const commands = [
    {
      id: 'projects',
      title: 'Open projects',
      subtitle: 'View every application and service',
      keywords: ['apps'],
      group: 'Navigation',
      shortcut: 'G P',
      href: '/projects'
    },
    {
      id: 'deploy',
      title: 'Deploy application',
      keywords: ['ship', 'release'],
      group: 'Actions',
      shortcut: 'D',
      action: () => console.log('Choose an application')
    }
  ]

  function run(command) {
    if (command.href) window.location.assign(command.href)
    else command.action?.()
  }
</script>

<Command bind:query {commands} label="Application commands" onselect={run} />

The framework binding changes; the command record and selection outcome do not.

Command records

title is the only field required for the default rendering and search. A stable id is strongly recommended.

FieldPurpose
idStable identity used for active-option relationships.
titleVisible command name and default search text.
subtitleOptional supporting text in the default item.
keywordsAdditional strings searched by the default filter.
groupVisible group heading. Missing groups become Other.
shortcutPresentational shortcut hint. It does not register a listener.
disabledKeeps the command visible but prevents selection.
destructiveExposes data-destructive for a caller-owned Tailwind recipe.

Add any application fields you need: href, route, icon, action, children, context, permission metadata, or something product-specific. Klean never reshapes or clones the record. Selection returns the same object you supplied.

Two data paths, one component

Use commands by default. Klean filters title and keywords, groups records by group, preserves their order, and highlights the first enabled result.

Use groups only when your application already owns ranking, recent history, permissions, remote results, or a domain-specific search. Pass an ordered object such as { Recent: [...], Projects: [...] }. Klean renders those groups exactly as supplied and still owns the accessible input and keyboard interaction; it does not filter or reorder them.

RankedCommands.vue
<script setup>
import { computed, ref } from 'vue'
import Command from '@/components/ui/command/Command.vue'
import { fuzzySearch } from '@/lib/search'

const query = ref('')
const recent = ref([])
const available = ref([])

const groups = computed(() => ({
  Recent: recent.value,
  Commands: fuzzySearch(available.value, query.value)
}))

function run(command) {
  command.action?.()
}
</script>

<template>
  <Command
    v-model:query="query"
    :groups="groups"
    label="Application commands"
    @select="run"
  />
</template>

This is not a second component or a mode to learn. It is the escape hatch that lets an existing search model keep doing its job.

API

InputDefaultPurpose
commands[]Flat records for the ordinary Klean filtering and grouping path.
groupsCaller-filtered and ordered { heading: commands } results. Takes precedence over commands.
queryinternalControlled query: Vue v-model:query, React query, or Svelte bind:query.
defaultQuery''Initial query for uncontrolled use.
labelSearch commandsAccessible name for the real combobox input.
placeholderType a command or search…Native input placeholder.
filternormalized substringBoolean (command, query) => visible predicate for commands.
autofocus / autoFocusfalseFocus the input when a newly opened palette needs it.
idgeneratedStable base ID when server and client markup require an explicit value.
class / classNameOrdinary Tailwind merged onto the root.
OutcomeVueReactSvelte
Query changed@update:queryonQueryChangeonquerychange
Record selected@selectonSelectonselect
Empty-query Escape@escapeonEscapeonescape
Empty-query Backspace@backonBackonback

Escape clears a non-empty query first. Escape and Backspace delegate only when the query is already empty, making surrounding dialogs and nested flows easy to control without hidden navigation policy.

Custom rendering without component ceremony

The default renderer covers a title, subtitle, shortcut, disabled state, groups, and an empty result. Customize only the seam your product owns:

AreaVueReactSvelte
Before input#prefixprefixprefix snippet
After input#suffixsuffixsuffix snippet
Above results#beforebeforebefore snippet
Each item#item="{ command, active }"renderItemitem snippet
Empty result#empty="{ query }"renderEmptyempty snippet
After list#footerfooterfooter snippet

Caller Tailwind remains the styling API. Stable data-slot hooks cover command, command-search, command-input, command-list, command-empty, command-group, command-group-heading, and command-item. Items also expose data-state, data-highlighted, and data-destructive.

There are no variant, tone, size, theme, icon, or part-class props.

Native Dialog palette

Command does not own a modal, global shortcut, or open state. Compose it inside Dialog, let native <dialog> provide focus containment and inert background behavior, and remove application listeners when their page unmounts:

ApplicationPalette.vue
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Command from '@/components/ui/command/Command.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'

const palette = ref()
const query = ref('')
const commands = [
  { id: 'projects', title: 'Open projects', group: 'Navigation' },
  { id: 'lookout', title: 'Open Lookout', group: 'Navigation' },
  { id: 'deploy', title: 'Deploy application', group: 'Actions' }
]

function openPalette(event) {
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
    event.preventDefault()
    palette.value?.showModal()
  }
}

function run(command) {
  query.value = ''
  palette.value?.close()
  console.log(command)
}

onMounted(() => document.addEventListener('keydown', openPalette))
onBeforeUnmount(() => document.removeEventListener('keydown', openPalette))
</script>

<template>
  <Button commandfor="application-palette" command="show-modal">
    Open commands <kbd aria-hidden="true">⌘ K</kbd>
  </Button>

  <Dialog
    id="application-palette"
    ref="palette"
    aria-label="Application commands"
    class="max-w-xl border-0 bg-transparent p-0 shadow-none"
  >
    <Command
      v-model:query="query"
      :commands="commands"
      autofocus
      label="Application commands"
      @select="run"
    />
  </Dialog>
</template>

The button uses native dialog commands. Native Dialog restores focus to its invoker. A keyboard shortcut has no invoker, so focus remains with the most sensible available target.

Nested application flows

Some commands lead to another list, such as “Deploy application” followed by an application. Keep that small state machine in the application and replace the commands records:

DeployCommands.vue
<script setup>
import { computed, ref } from 'vue'
import Command from '@/components/ui/command/Command.vue'

const query = ref('')
const level = ref('root')
const applications = ['Storefront', 'Worker', 'Documentation']
const commands = computed(() =>
  level.value === 'root'
    ? [
        {
          id: 'deploy',
          title: 'Deploy application',
          group: 'Actions',
          children: () => applications
        }
      ]
    : applications.map((title) => ({
        id: title.toLowerCase(),
        title,
        group: 'Applications'
      }))
)

function select(command) {
  if (command.children) {
    level.value = 'applications'
    query.value = ''
    return
  }
  console.log(`Deploy ${command.title}`)
}

function back(event) {
  if (level.value === 'root') return
  event.preventDefault()
  level.value = 'root'
  query.value = ''
}
</script>

<template>
  <Command
    v-model:query="query"
    :commands="commands"
    :placeholder="
      level === 'root' ? 'Search commands' : 'Choose an application'
    "
    label="Deployment commands"
    @select="select"
    @back="back"
    @escape="back"
  />
</template>

The original record can carry children, action, or any other application field. Klean only reports selection.

Keyboard and accessibility

  • DOM focus stays on one real text input while aria-activedescendant identifies the highlighted option.
  • Arrow Down and Arrow Up wrap across enabled visible commands.
  • Home and End move to the enabled edges.
  • Enter selects the highlighted command once.
  • Disabled commands remain understandable but are skipped by keyboard and pointer selection.
  • Pointer movement may highlight an item without stealing input focus.
  • IME composition is never interpreted as command navigation.
  • Dynamic permission or data changes recover to a valid active descendant.
  • Long lists reveal the active command as it moves.
  • Tab is untouched and continues through the document normally.
  • Empty-result feedback is written into an already-mounted polite status region.

The before seam is a sibling above the listbox, so nested flows may place a real Back button there without introducing an interactive descendant inside the composite results widget.

The built-in label names the input and groups label themselves. Visible item content should make the action or destination clear without relying on an icon or shortcut.

Async work and durability

Command reports a record; the application decides what accepting it means. Navigate, enter a nested step, or begin async work. Show truthful pending state, disable only unsafe repeats, and close the surrounding surface when acceptance completes. Keep it open with a visible error when recovery belongs there.

Query, active command, and palette visibility are ephemeral by default. Do not put them in local storage or the URL merely because they can be persisted. Preserve the resulting route, task, or form state when the product needs durability.

There is no hidden promise queue, toast coupling, deploy API, router adapter, permission model, or persistence policy.

When to use Command

Use Command when typed text narrows application actions or destinations: a command palette, quick-create surface, operations launcher, or searchable step in a task.

  • Use Combobox when the result commits one form or relationship value.
  • Use Menu for a short action list that does not need a query.
  • Use Dialog when the surface should be modal; Command composes inside it.
  • Use Input when arbitrary text, rather than a record, is the result.
  • Use dedicated site search when results are documents ranked by a search index.
  • Combobox — searches and commits one data value.
  • Menu — a short action list without text search.
  • Dialog — an optional native modal container.
  • Popover — non-modal floating content with ordinary Tab order.
  • Input — free-form text rather than command selection.
  • Toast — feedback after application work begins or completes.

Complete framework source

Vue

Command.vue
<script setup>
import { computed, nextTick, ref, toRaw, useAttrs, useId, watch } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

function normalize(value) {
  return String(value ?? '')
    .normalize('NFKD')
    .toLocaleLowerCase()
    .replace(/\p{Diacritic}/gu, '')
}

function defaultFilter(command, query) {
  const needle = normalize(query).trim()
  if (!needle) return true
  return normalize(
    [command.title, ...(command.keywords ?? [])].filter(Boolean).join(' ')
  ).includes(needle)
}

const props = defineProps({
  /** Flat command records. Klean filters and groups these by `group`. */
  commands: { type: Array, default: () => [] },
  /** Caller-filtered groups. Useful for ranked search, Recent, and nested flows. */
  groups: { type: Object, default: undefined },
  /** Framework-native controlled search query. Omit for uncontrolled use. */
  query: { type: String, default: undefined },
  /** Initial query when `query` is not controlled. */
  defaultQuery: { type: String, default: '' },
  /** Accessible name for the real search input. */
  label: { type: String, default: 'Search commands' },
  placeholder: { type: String, default: 'Type a command or search…' },
  /** Boolean visibility predicate for flat `commands`. */
  filter: { type: Function, default: undefined },
  autofocus: { type: Boolean, default: false },
  /** Stable base ID for the input/listbox relationship. */
  id: { type: String, default: undefined }
})

const emit = defineEmits(['update:query', 'select', 'escape', 'back'])
const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const root = ref()
const input = ref()
const internalQuery = ref(props.defaultQuery)
const activeKey = ref()

const isControlled = computed(() => props.query !== undefined)
const currentQuery = computed(() =>
  isControlled.value ? props.query : internalQuery.value
)
const controlId = computed(() => props.id ?? `klean-command-${generatedId}`)
const inputId = computed(() => `${controlId.value}-input`)
const listId = computed(() => `${controlId.value}-list`)

const sourceGroups = computed(() => {
  if (props.groups !== undefined) {
    return Object.entries(props.groups).map(([heading, commands]) => ({
      heading,
      commands: Array.isArray(commands) ? commands : []
    }))
  }

  const groups = new Map()
  for (const command of props.commands) {
    if (!(props.filter ?? defaultFilter)(command, currentQuery.value)) continue
    const heading = command.group || 'Other'
    if (!groups.has(heading)) groups.set(heading, [])
    groups.get(heading).push(command)
  }
  return [...groups].map(([heading, commands]) => ({ heading, commands }))
})

const commandGroups = computed(() =>
  sourceGroups.value
    .map((group, groupIndex) => ({
      heading: group.heading,
      headingId: `${controlId.value}-group-${groupIndex}`,
      entries: group.commands.map((command, commandIndex) => {
        const identity = String(command.id ?? command.title ?? commandIndex)
          .replace(/[^a-zA-Z0-9_-]/g, '-')
          .replace(/-+/g, '-')
        return {
          command,
          key: `${groupIndex}:${commandIndex}:${identity}`,
          optionId: `${controlId.value}-option-${groupIndex}-${commandIndex}-${identity}`
        }
      })
    }))
    .filter((group) => group.entries.length)
)
const entries = computed(() =>
  commandGroups.value.flatMap((group) => group.entries)
)
const enabledEntries = computed(() =>
  entries.value.filter((entry) => !entry.command.disabled)
)
const activeEntry = computed(() =>
  enabledEntries.value.find((entry) => entry.key === activeKey.value)
)
const rootClasses = computed(() =>
  twMerge(
    'w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-950 shadow-lg dark:border-gray-700 dark:bg-gray-950 dark:text-white',
    attrs.class
  )
)
const rootAttrs = computed(() => {
  const {
    class: _class,
    onKeydown: _onKeydown,
    onKeyDown: _onKeyDown,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    ...rest
  } = attrs
  return rest
})

function setQuery(nextQuery) {
  if (!isControlled.value) internalQuery.value = nextQuery
  emit('update:query', nextQuery)
}

function revealActive() {
  nextTick(() => {
    if (!activeEntry.value) return
    root.value
      ?.querySelector?.(`[data-command-key="${activeEntry.value.key}"]`)
      ?.scrollIntoView?.({ block: 'nearest' })
  })
}

function setActive(key) {
  if (!enabledEntries.value.some((entry) => entry.key === key)) return
  activeKey.value = key
}

function move(step) {
  const enabled = enabledEntries.value
  if (!enabled.length) return
  const current = enabled.findIndex((entry) => entry.key === activeKey.value)
  const next =
    current < 0
      ? step > 0
        ? 0
        : enabled.length - 1
      : (current + step + enabled.length) % enabled.length
  activeKey.value = enabled[next].key
  revealActive()
}

function moveTo(edge) {
  const enabled = enabledEntries.value
  if (!enabled.length) return
  activeKey.value = edge === 'last' ? enabled.at(-1).key : enabled[0].key
  revealActive()
}

function select(entry = activeEntry.value) {
  if (!entry || entry.command.disabled) return
  emit('select', toRaw(entry.command))
}

function handleInput(event) {
  setQuery(event.currentTarget.value)
}

function handleKeydown(event) {
  const listener = attrs.onKeydown ?? attrs.onKeyDown
  for (const callback of Array.isArray(listener) ? listener : [listener]) {
    callback?.(event)
  }
  if (event.defaultPrevented || event.isComposing || event.keyCode === 229) {
    return
  }

  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
    event.preventDefault()
    move(event.key === 'ArrowDown' ? 1 : -1)
  } else if (event.key === 'Home') {
    event.preventDefault()
    moveTo('first')
  } else if (event.key === 'End') {
    event.preventDefault()
    moveTo('last')
  } else if (event.key === 'Enter') {
    if (!activeEntry.value) return
    event.preventDefault()
    select()
  } else if (event.key === 'Escape') {
    if (currentQuery.value) {
      event.preventDefault()
      event.stopPropagation()
      setQuery('')
    } else {
      emit('escape', event)
    }
  } else if (event.key === 'Backspace' && !currentQuery.value) {
    emit('back', event)
  }
}

function handlePointermove(event, entry) {
  if (entry.command.disabled || event.pointerType === 'touch') return
  setActive(entry.key)
}

function handleMousedown(event) {
  event.preventDefault()
}

function focus(options) {
  input.value?.focus(options)
}

watch(
  [enabledEntries, currentQuery],
  ([enabled, query], previous = []) => {
    const queryChanged = query !== previous[1]
    if (
      queryChanged ||
      !enabled.some((entry) => entry.key === activeKey.value)
    ) {
      activeKey.value = enabled[0]?.key
    }
    revealActive()
  },
  { immediate: true, flush: 'post' }
)

defineExpose({ focus, clear: () => setQuery('') })
</script>

<template>
  <div
    v-bind="rootAttrs"
    ref="root"
    data-slot="command"
    :data-state="entries.length ? 'results' : 'empty'"
    :class="rootClasses"
  >
    <div
      data-slot="command-search"
      class="flex items-center border-b border-gray-200 px-4 dark:border-gray-800"
    >
      <slot name="prefix" />
      <input
        :id="inputId"
        ref="input"
        type="text"
        role="combobox"
        aria-autocomplete="list"
        aria-expanded="true"
        :aria-label="label"
        :aria-controls="listId"
        :aria-activedescendant="activeEntry?.optionId"
        autocomplete="off"
        :autofocus="autofocus"
        :placeholder="placeholder"
        :value="currentQuery"
        data-slot="command-input"
        class="min-h-11 w-full border-0 bg-transparent px-0 py-3 text-base text-gray-950 outline-none placeholder:text-gray-500 focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:focus-visible:outline-white"
        @input="handleInput"
        @keydown="handleKeydown"
      />
      <slot name="suffix" />
    </div>

    <slot name="before" />

    <div
      :id="listId"
      role="listbox"
      :aria-label="`${label} results`"
      data-slot="command-list"
      class="max-h-72 overflow-y-auto overscroll-contain p-1.5"
    >
      <div
        v-for="group in commandGroups"
        :key="group.headingId"
        role="group"
        :aria-labelledby="group.headingId"
        data-slot="command-group"
      >
        <div
          :id="group.headingId"
          data-slot="command-group-heading"
          class="px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
        >
          {{ group.heading }}
        </div>

        <div
          v-for="entry in group.entries"
          :id="entry.optionId"
          :key="entry.key"
          role="option"
          :aria-selected="entry.key === activeKey"
          :aria-disabled="entry.command.disabled || undefined"
          data-slot="command-item"
          :data-command-key="entry.key"
          :data-state="entry.key === activeKey ? 'active' : 'inactive'"
          :data-highlighted="entry.key === activeKey ? '' : undefined"
          :data-destructive="entry.command.destructive ? '' : undefined"
          class="flex min-h-11 cursor-pointer select-none items-center gap-3 rounded-md px-3 py-2 text-sm outline-none data-highlighted:bg-gray-100 data-highlighted:text-gray-950 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:data-highlighted:bg-gray-800 dark:data-highlighted:text-white"
          @mousedown="handleMousedown"
          @pointermove="handlePointermove($event, entry)"
          @click="select(entry)"
        >
          <slot
            name="item"
            :command="entry.command"
            :active="entry.key === activeKey"
          >
            <span class="flex min-w-0 flex-1 flex-col">
              <span class="truncate">{{ entry.command.title }}</span>
              <span
                v-if="entry.command.subtitle"
                class="truncate text-xs text-gray-500 dark:text-gray-400"
              >
                {{ entry.command.subtitle }}
              </span>
            </span>
            <kbd
              v-if="entry.command.shortcut"
              aria-hidden="true"
              class="ml-auto shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400"
            >
              {{ entry.command.shortcut }}
            </kbd>
          </slot>
        </div>
      </div>
    </div>

    <div
      data-slot="command-empty"
      :class="
        entries.length
          ? 'sr-only'
          : 'py-10 text-center text-sm text-gray-500 dark:text-gray-400'
      "
      role="status"
      aria-atomic="true"
    >
      <slot v-if="!entries.length" name="empty" :query="currentQuery">
        No matching command.
      </slot>
    </div>

    <slot name="footer" />
  </div>
</template>

React

Command.jsx
import {
  forwardRef,
  useCallback,
  useEffect,
  useId,
  useImperativeHandle,
  useMemo,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'

function normalize(value) {
  return String(value ?? '')
    .normalize('NFKD')
    .toLocaleLowerCase()
    .replace(/\p{Diacritic}/gu, '')
}

function defaultFilter(command, query) {
  const needle = normalize(query).trim()
  if (!needle) return true
  return normalize(
    [command.title, ...(command.keywords ?? [])].filter(Boolean).join(' ')
  ).includes(needle)
}

const Command = forwardRef(function Command(
  {
    commands = [],
    groups,
    query: controlledQuery,
    defaultQuery = '',
    label = 'Search commands',
    placeholder = 'Type a command or search…',
    filter = defaultFilter,
    autoFocus = false,
    id,
    className,
    prefix,
    suffix,
    before,
    footer,
    renderItem,
    renderEmpty,
    onQueryChange,
    onSelect,
    onEscape,
    onBack,
    onKeyDown,
    ...rootProps
  },
  forwardedRef
) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const controlId = id ?? `klean-command-${generatedId}`
  const inputId = `${controlId}-input`
  const listId = `${controlId}-list`
  const rootRef = useRef(null)
  const inputRef = useRef(null)
  const [internalQuery, setInternalQuery] = useState(defaultQuery)
  const [activeKey, setActiveKey] = useState()
  const isControlled = controlledQuery !== undefined
  const currentQuery = isControlled ? controlledQuery : internalQuery
  const previousQueryRef = useRef(currentQuery)

  const sourceGroups = useMemo(() => {
    if (groups !== undefined) {
      return Object.entries(groups).map(([heading, groupedCommands]) => ({
        heading,
        commands: Array.isArray(groupedCommands) ? groupedCommands : []
      }))
    }

    const collected = new Map()
    for (const command of commands) {
      if (!filter(command, currentQuery)) continue
      const heading = command.group || 'Other'
      if (!collected.has(heading)) collected.set(heading, [])
      collected.get(heading).push(command)
    }
    return [...collected].map(([heading, groupedCommands]) => ({
      heading,
      commands: groupedCommands
    }))
  }, [commands, currentQuery, filter, groups])

  const commandGroups = useMemo(
    () =>
      sourceGroups
        .map((group, groupIndex) => ({
          heading: group.heading,
          headingId: `${controlId}-group-${groupIndex}`,
          entries: group.commands.map((command, commandIndex) => {
            const identity = String(command.id ?? command.title ?? commandIndex)
              .replace(/[^a-zA-Z0-9_-]/g, '-')
              .replace(/-+/g, '-')
            return {
              command,
              key: `${groupIndex}:${commandIndex}:${identity}`,
              optionId: `${controlId}-option-${groupIndex}-${commandIndex}-${identity}`
            }
          })
        }))
        .filter((group) => group.entries.length),
    [controlId, sourceGroups]
  )
  const entries = useMemo(
    () => commandGroups.flatMap((group) => group.entries),
    [commandGroups]
  )
  const enabledEntries = useMemo(
    () => entries.filter((entry) => !entry.command.disabled),
    [entries]
  )
  const activeEntry = enabledEntries.find((entry) => entry.key === activeKey)

  const setQuery = useCallback(
    (nextQuery) => {
      if (!isControlled) setInternalQuery(nextQuery)
      onQueryChange?.(nextQuery)
    },
    [isControlled, onQueryChange]
  )

  useImperativeHandle(
    forwardedRef,
    () => ({
      focus: (options) => inputRef.current?.focus(options),
      clear: () => setQuery('')
    }),
    [setQuery]
  )

  const revealActive = useCallback((entry) => {
    if (!entry) return
    queueMicrotask(() => {
      rootRef.current
        ?.querySelector?.(`[data-command-key="${entry.key}"]`)
        ?.scrollIntoView?.({ block: 'nearest' })
    })
  }, [])

  useEffect(() => {
    const queryChanged = previousQueryRef.current !== currentQuery
    previousQueryRef.current = currentQuery
    if (
      queryChanged ||
      !enabledEntries.some((entry) => entry.key === activeKey)
    ) {
      const next = enabledEntries[0]
      setActiveKey(next?.key)
      revealActive(next)
    }
  }, [activeKey, currentQuery, enabledEntries, revealActive])

  const move = useCallback(
    (step) => {
      if (!enabledEntries.length) return
      const current = enabledEntries.findIndex(
        (entry) => entry.key === activeKey
      )
      const next =
        current < 0
          ? step > 0
            ? 0
            : enabledEntries.length - 1
          : (current + step + enabledEntries.length) % enabledEntries.length
      const entry = enabledEntries[next]
      setActiveKey(entry.key)
      revealActive(entry)
    },
    [activeKey, enabledEntries, revealActive]
  )

  const moveTo = useCallback(
    (edge) => {
      if (!enabledEntries.length) return
      const entry = edge === 'last' ? enabledEntries.at(-1) : enabledEntries[0]
      setActiveKey(entry.key)
      revealActive(entry)
    },
    [enabledEntries, revealActive]
  )

  const select = useCallback(
    (entry = activeEntry) => {
      if (!entry || entry.command.disabled) return
      onSelect?.(entry.command)
    },
    [activeEntry, onSelect]
  )

  function handleKeyDown(event) {
    onKeyDown?.(event)
    if (event.defaultPrevented || event.isComposing || event.keyCode === 229) {
      return
    }

    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault()
      move(event.key === 'ArrowDown' ? 1 : -1)
    } else if (event.key === 'Home') {
      event.preventDefault()
      moveTo('first')
    } else if (event.key === 'End') {
      event.preventDefault()
      moveTo('last')
    } else if (event.key === 'Enter') {
      if (!activeEntry) return
      event.preventDefault()
      select()
    } else if (event.key === 'Escape') {
      if (currentQuery) {
        event.preventDefault()
        event.stopPropagation()
        setQuery('')
      } else {
        onEscape?.(event)
      }
    } else if (event.key === 'Backspace' && !currentQuery) {
      onBack?.(event)
    }
  }

  return (
    <div
      {...rootProps}
      ref={rootRef}
      data-slot="command"
      data-state={entries.length ? 'results' : 'empty'}
      className={twMerge(
        'w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-950 shadow-lg dark:border-gray-700 dark:bg-gray-950 dark:text-white',
        className
      )}
    >
      <div
        data-slot="command-search"
        className="flex items-center border-b border-gray-200 px-4 dark:border-gray-800"
      >
        {prefix}
        <input
          ref={inputRef}
          id={inputId}
          type="text"
          role="combobox"
          aria-autocomplete="list"
          aria-expanded="true"
          aria-label={label}
          aria-controls={listId}
          aria-activedescendant={activeEntry?.optionId}
          autoComplete="off"
          autoFocus={autoFocus}
          placeholder={placeholder}
          value={currentQuery}
          data-slot="command-input"
          className="min-h-11 w-full border-0 bg-transparent px-0 py-3 text-base text-gray-950 outline-none placeholder:text-gray-500 focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:focus-visible:outline-white"
          onChange={(event) => setQuery(event.currentTarget.value)}
          onKeyDown={handleKeyDown}
        />
        {suffix}
      </div>

      {before}

      <div
        id={listId}
        role="listbox"
        aria-label={`${label} results`}
        data-slot="command-list"
        className="max-h-72 overflow-y-auto overscroll-contain p-1.5"
      >
        {commandGroups.map((group) => (
          <div
            key={group.headingId}
            role="group"
            aria-labelledby={group.headingId}
            data-slot="command-group"
          >
            <div
              id={group.headingId}
              data-slot="command-group-heading"
              className="px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
            >
              {group.heading}
            </div>
            {group.entries.map((entry) => {
              const active = entry.key === activeKey
              return (
                <div
                  key={entry.key}
                  id={entry.optionId}
                  role="option"
                  aria-selected={active}
                  aria-disabled={entry.command.disabled || undefined}
                  data-slot="command-item"
                  data-command-key={entry.key}
                  data-state={active ? 'active' : 'inactive'}
                  data-highlighted={active ? '' : undefined}
                  data-destructive={entry.command.destructive ? '' : undefined}
                  className="flex min-h-11 cursor-pointer select-none items-center gap-3 rounded-md px-3 py-2 text-sm outline-none data-highlighted:bg-gray-100 data-highlighted:text-gray-950 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:data-highlighted:bg-gray-800 dark:data-highlighted:text-white"
                  onMouseDown={(event) => event.preventDefault()}
                  onPointerMove={(event) => {
                    if (
                      entry.command.disabled ||
                      event.pointerType === 'touch'
                    ) {
                      return
                    }
                    setActiveKey(entry.key)
                  }}
                  onClick={() => select(entry)}
                >
                  {renderItem?.({ command: entry.command, active }) ?? (
                    <>
                      <span className="flex min-w-0 flex-1 flex-col">
                        <span className="truncate">{entry.command.title}</span>
                        {entry.command.subtitle ? (
                          <span className="truncate text-xs text-gray-500 dark:text-gray-400">
                            {entry.command.subtitle}
                          </span>
                        ) : null}
                      </span>
                      {entry.command.shortcut ? (
                        <kbd
                          aria-hidden="true"
                          className="ml-auto shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400"
                        >
                          {entry.command.shortcut}
                        </kbd>
                      ) : null}
                    </>
                  )}
                </div>
              )
            })}
          </div>
        ))}
      </div>

      <div
        data-slot="command-empty"
        className={
          entries.length
            ? 'sr-only'
            : 'py-10 text-center text-sm text-gray-500 dark:text-gray-400'
        }
        role="status"
        aria-atomic="true"
      >
        {!entries.length
          ? (renderEmpty?.({ query: currentQuery }) ?? 'No matching command.')
          : null}
      </div>

      {footer}
    </div>
  )
})

export default Command

Svelte

Command.svelte
<script>
  import { untrack } from "svelte";
  import { twMerge } from "tailwind-merge";

  function normalize(value) {
    return String(value ?? "")
      .normalize("NFKD")
      .toLocaleLowerCase()
      .replace(/\p{Diacritic}/gu, "");
  }

  function defaultFilter(command, query) {
    const needle = normalize(query).trim();
    if (!needle) return true;
    return normalize(
      [command.title, ...(command.keywords ?? [])].filter(Boolean).join(" "),
    ).includes(needle);
  }

  const componentIdentity = $props.id();
  const generatedId = componentIdentity.replace(/[^a-zA-Z0-9_-]/g, "");
  let {
    commands = [],
    groups,
    query = $bindable(),
    defaultQuery = "",
    label = "Search commands",
    placeholder = "Type a command or search…",
    filter = defaultFilter,
    autofocus = false,
    id,
    class: className = "",
    prefix,
    suffix,
    before,
    item,
    empty,
    footer,
    onquerychange,
    onselect,
    onescape,
    onback,
    onkeydown,
    "data-slot": _dataSlot,
    "data-state": _dataState,
    children: _children,
    ...rootProps
  } = $props();

  let rootElement;
  let inputElement;
  let internalQuery = $state(untrack(() => defaultQuery));
  let activeKey = $state();
  let currentQuery = $derived(query !== undefined ? query : internalQuery);
  let controlId = $derived(id ?? `klean-command-${generatedId}`);
  let inputId = $derived(`${controlId}-input`);
  let listId = $derived(`${controlId}-list`);
  let sourceGroups = $derived.by(() => {
    if (groups !== undefined) {
      return Object.entries(groups).map(([heading, groupedCommands]) => ({
        heading,
        commands: Array.isArray(groupedCommands) ? groupedCommands : [],
      }));
    }

    const collected = new Map();
    for (const command of commands) {
      if (!filter(command, currentQuery)) continue;
      const heading = command.group || "Other";
      if (!collected.has(heading)) collected.set(heading, []);
      collected.get(heading).push(command);
    }
    return [...collected].map(([heading, groupedCommands]) => ({
      heading,
      commands: groupedCommands,
    }));
  });
  let commandGroups = $derived(
    sourceGroups
      .map((group, groupIndex) => ({
        heading: group.heading,
        headingId: `${controlId}-group-${groupIndex}`,
        entries: group.commands.map((command, commandIndex) => {
          const identity = String(command.id ?? command.title ?? commandIndex)
            .replace(/[^a-zA-Z0-9_-]/g, "-")
            .replace(/-+/g, "-");
          return {
            command,
            key: `${groupIndex}:${commandIndex}:${identity}`,
            optionId: `${controlId}-option-${groupIndex}-${commandIndex}-${identity}`,
          };
        }),
      }))
      .filter((group) => group.entries.length),
  );
  let entries = $derived(commandGroups.flatMap((group) => group.entries));
  let enabledEntries = $derived(
    entries.filter((entry) => !entry.command.disabled),
  );
  let activeEntry = $derived(
    enabledEntries.find((entry) => entry.key === activeKey),
  );
  let previousQuery = untrack(() => currentQuery);

  function setQuery(nextQuery) {
    internalQuery = nextQuery;
    query = nextQuery;
    onquerychange?.(nextQuery);
  }

  function revealActive(entry = activeEntry) {
    if (!entry) return;
    queueMicrotask(() => {
      rootElement
        ?.querySelector?.(`[data-command-key="${entry.key}"]`)
        ?.scrollIntoView?.({ block: "nearest" });
    });
  }

  function move(step) {
    if (!enabledEntries.length) return;
    const current = enabledEntries.findIndex(
      (entry) => entry.key === activeKey,
    );
    const next =
      current < 0
        ? step > 0
          ? 0
          : enabledEntries.length - 1
        : (current + step + enabledEntries.length) % enabledEntries.length;
    activeKey = enabledEntries[next].key;
    revealActive();
  }

  function moveTo(edge) {
    if (!enabledEntries.length) return;
    activeKey =
      edge === "last" ? enabledEntries.at(-1).key : enabledEntries[0].key;
    revealActive();
  }

  function select(entry = activeEntry) {
    if (!entry || entry.command.disabled) return;
    onselect?.(entry.command);
  }

  function handleInput(event) {
    setQuery(event.currentTarget.value);
  }

  function handleKeydown(event) {
    onkeydown?.(event);
    if (event.defaultPrevented || event.isComposing || event.keyCode === 229) {
      return;
    }

    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      move(event.key === "ArrowDown" ? 1 : -1);
    } else if (event.key === "Home") {
      event.preventDefault();
      moveTo("first");
    } else if (event.key === "End") {
      event.preventDefault();
      moveTo("last");
    } else if (event.key === "Enter") {
      if (!activeEntry) return;
      event.preventDefault();
      select();
    } else if (event.key === "Escape") {
      if (currentQuery) {
        event.preventDefault();
        event.stopPropagation();
        setQuery("");
      } else {
        onescape?.(event);
      }
    } else if (event.key === "Backspace" && !currentQuery) {
      onback?.(event);
    }
  }

  function handlePointermove(event, entry) {
    if (entry.command.disabled || event.pointerType === "touch") return;
    activeKey = entry.key;
  }

  function handleOptionKeydown(event, entry) {
    if (event.key !== "Enter" && event.key !== " ") return;
    event.preventDefault();
    select(entry);
  }

  $effect(() => {
    const enabled = enabledEntries;
    const nextQuery = currentQuery;
    const queryChanged = nextQuery !== previousQuery;
    previousQuery = nextQuery;
    if (queryChanged || !enabled.some((entry) => entry.key === activeKey)) {
      activeKey = enabled[0]?.key;
    }
    revealActive();
  });

  export function focus(options) {
    inputElement?.focus(options);
  }

  export function clear() {
    setQuery("");
  }
</script>

<div
  {...rootProps}
  bind:this={rootElement}
  data-slot="command"
  data-state={entries.length ? "results" : "empty"}
  class={twMerge(
    "w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-950 shadow-lg dark:border-gray-700 dark:bg-gray-950 dark:text-white",
    className,
  )}
>
  <div
    data-slot="command-search"
    class="flex items-center border-b border-gray-200 px-4 dark:border-gray-800"
  >
    {@render prefix?.()}
    <!-- Autofocus is opt-in and appropriate when a Command opens as a palette. -->
    <!-- svelte-ignore a11y_autofocus -->
    <input
      bind:this={inputElement}
      id={inputId}
      type="text"
      role="combobox"
      aria-autocomplete="list"
      aria-expanded="true"
      aria-label={label}
      aria-controls={listId}
      aria-activedescendant={activeEntry?.optionId}
      autocomplete="off"
      {autofocus}
      {placeholder}
      value={currentQuery}
      data-slot="command-input"
      class="min-h-11 w-full border-0 bg-transparent px-0 py-3 text-base text-gray-950 outline-none placeholder:text-gray-500 focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-gray-950 dark:text-white dark:placeholder:text-gray-400 dark:focus-visible:outline-white"
      oninput={handleInput}
      onkeydown={handleKeydown}
    />
    {@render suffix?.()}
  </div>

  {@render before?.()}

  <div
    id={listId}
    role="listbox"
    aria-label={`${label} results`}
    data-slot="command-list"
    class="max-h-72 overflow-y-auto overscroll-contain p-1.5"
  >
    {#each commandGroups as group (group.headingId)}
      <div
        role="group"
        aria-labelledby={group.headingId}
        data-slot="command-group"
      >
        <div
          id={group.headingId}
          data-slot="command-group-heading"
          class="px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
        >
          {group.heading}
        </div>

        {#each group.entries as entry (entry.key)}
          <div
            id={entry.optionId}
            role="option"
            tabindex="-1"
            aria-selected={entry.key === activeKey}
            aria-disabled={entry.command.disabled || undefined}
            data-slot="command-item"
            data-command-key={entry.key}
            data-state={entry.key === activeKey ? "active" : "inactive"}
            data-highlighted={entry.key === activeKey ? "" : undefined}
            data-destructive={entry.command.destructive ? "" : undefined}
            class="flex min-h-11 cursor-pointer select-none items-center gap-3 rounded-md px-3 py-2 text-sm outline-none data-highlighted:bg-gray-100 data-highlighted:text-gray-950 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:data-highlighted:bg-gray-800 dark:data-highlighted:text-white"
            onmousedown={(event) => event.preventDefault()}
            onpointermove={(event) => handlePointermove(event, entry)}
            onkeydown={(event) => handleOptionKeydown(event, entry)}
            onclick={() => select(entry)}
          >
            {#if item}
              {@render item({
                command: entry.command,
                active: entry.key === activeKey,
              })}
            {:else}
              <span class="flex min-w-0 flex-1 flex-col">
                <span class="truncate">{entry.command.title}</span>
                {#if entry.command.subtitle}
                  <span
                    class="truncate text-xs text-gray-500 dark:text-gray-400"
                  >
                    {entry.command.subtitle}
                  </span>
                {/if}
              </span>
              {#if entry.command.shortcut}
                <kbd
                  aria-hidden="true"
                  class="ml-auto shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400"
                >
                  {entry.command.shortcut}
                </kbd>
              {/if}
            {/if}
          </div>
        {/each}
      </div>
    {/each}
  </div>

  <div
    data-slot="command-empty"
    class={entries.length
      ? "sr-only"
      : "py-10 text-center text-sm text-gray-500 dark:text-gray-400"}
    role="status"
    aria-atomic="true"
  >
    {#if !entries.length}
      {#if empty}
        {@render empty({ query: currentQuery })}
      {:else}
        No matching command.
      {/if}
    {/if}
  </div>

  {@render footer?.()}
</div>

All open source projects are released under the MIT License.