Command
commanduiSearchable command palette a la Cmd-K — keyboard-driven menu with grouped items, icons, shortcuts, and filtering. Use as a global launcher (mounted in a Dialog via CommandDialog) or inline as a typeahead select. Hand-rolled runes behavior: filter, arrow/enter navigation, highlight.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/command.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/command.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/command.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/command.jsonhttps://uipkge.dev/r/svelte/command.jsonInstalls to:src/lib/components/ui/command/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueSelected value. Updated on item select. | string | — | optional |
onValueChange | (value: string) => void | — | optional |
filterCustom filter — mirrors cmdk `filter`. Omit for the default normalized-substring match. | CommandFilter | — | optional |
shouldFilterWhen false, skip filtering entirely (consumer filters externally). Mirrors cmdk `shouldFilter`. Default true. | boolean | true | optional |
loopWhen true, arrow navigation wraps around the ends. Mirrors cmdk `loop`. Default false. | boolean | false | optional |
ref | HTMLDivElement | null | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
CommandItemRegistrationinterface CommandItemRegistration {
value: string
text: string
disabled: boolean
groupId: string | undefined
keywords: string[] | undefined
onSelect: (value: string) => void
}CommandContextValueinterface CommandContextValue {
readonly search: string
setSearch: (search: string) => void
readonly count: number
readonly listId: string
readonly activeDescendant: string | undefined
isItemVisible: (id: string) => boolean
isGroupVisible: (groupId: string) => boolean
isHighlighted: (id: string) => boolean
setHighlighted: (id: string | null) => void
registerItem: (id: string, entry: CommandItemRegistration) => void
unregisterItem: (id: string) => void
registerGroup: (id: string) => void
unregisterGroup: (id: string) => void
moveHighlight: (direction: 1 | -1) => void
selectHighlighted: () => void
selectItem: (id: string) => void
setListElement: (el: HTMLElement | null) => void
}npm dependencies
Includes
Used by
Files installed (11)
src/lib/components/ui/command/Command.svelte6.3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { CommandFilter } from './context' export interface CommandProps extends HTMLAttributes<HTMLDivElement> { /** Selected value. Updated on item select. */ value?: string onValueChange?: (value: string) => void /** Custom filter — mirrors cmdk `filter`. Omit for the default normalized-substring match. */ filter?: CommandFilter /** When false, skip filtering entirely (consumer filters externally). Mirrors cmdk `shouldFilter`. Default true. */ shouldFilter?: boolean /** When true, arrow navigation wraps around the ends. Mirrors cmdk `loop`. Default false. */ loop?: boolean ref?: HTMLDivElement | null } let commandIdCounter = 0 </script> <script lang="ts"> import { SvelteMap, SvelteSet } from 'svelte/reactivity' import { cn } from '$lib/utils' import { normalizeSearch, setCommandContext, type CommandItemRegistration } from './context' let { class: className, value = $bindable(''), onValueChange, filter, shouldFilter = true, loop = false, children, ref = $bindable(null), ...restProps }: CommandProps = $props() commandIdCounter += 1 const listId = `uipkge-command-list-${commandIdCounter}` let search = $state('') let highlightedId = $state<string | null>(null) let listEl: HTMLElement | null = null const allItems = new SvelteMap<string, CommandItemRegistration>() const allGroups = new SvelteMap<string, SvelteSet<string>>() const isFiltering = $derived(!!search && shouldFilter) const scores = $derived.by(() => { const map = new Map<string, boolean>() if (!isFiltering) { for (const id of allItems.keys()) map.set(id, true) return map } const q = normalizeSearch(search) for (const [id, entry] of allItems) { if (filter) { const result = filter(entry.value, search, entry.keywords) map.set(id, typeof result === 'number' ? result > 0 : result) } else { map.set(id, normalizeSearch(entry.text || entry.value).includes(q)) } } return map }) const visibleGroups = $derived.by(() => { const set = new Set<string>() if (!isFiltering) { for (const id of allGroups.keys()) set.add(id) return set } for (const [groupId, members] of allGroups) { for (const itemId of members) { if (scores.get(itemId)) { set.add(groupId) break } } } return set }) const count = $derived(isFiltering ? [...scores.values()].filter(Boolean).length : allItems.size) const activeDescendant = $derived(highlightedId ? `${listId}-item-${highlightedId}` : undefined) /** Visible, enabled ids in DOM order (falls back to registration order). */ function orderedVisibleIds(): string[] { if (!listEl) return [...scores].filter(([, v]) => v).map(([id]) => id) const ids: string[] = [] for (const el of listEl.querySelectorAll('[data-command-item-id]')) { const id = el.getAttribute('data-command-item-id')! const entry = allItems.get(id) if (entry && !entry.disabled && (scores.get(id) ?? true)) ids.push(id) } return ids } function moveHighlight(direction: 1 | -1) { const ids = orderedVisibleIds() if (!ids.length) return const current = highlightedId ? ids.indexOf(highlightedId) : -1 let next: number if (current === -1) { next = direction === 1 ? 0 : ids.length - 1 } else if (loop) { next = (current + direction + ids.length) % ids.length } else { // cmdk default: clamp at the ends instead of wrapping. next = Math.min(ids.length - 1, Math.max(0, current + direction)) } highlightedId = ids[next]! listEl?.querySelector(`[data-command-item-id="${highlightedId}"]`)?.scrollIntoView({ block: 'nearest' }) } function selectItem(id: string) { const entry = allItems.get(id) if (!entry || entry.disabled) return value = entry.value onValueChange?.(entry.value) search = '' highlightedId = null entry.onSelect(entry.value) } function selectHighlighted() { if (highlightedId) selectItem(highlightedId) } setCommandContext({ get search() { return search }, setSearch(v) { search = v highlightedId = null }, get count() { return count }, get listId() { return listId }, get activeDescendant() { return activeDescendant }, isItemVisible: (id) => (isFiltering ? (scores.get(id) ?? true) : true), isGroupVisible: (groupId) => (isFiltering ? visibleGroups.has(groupId) : true), isHighlighted: (id) => highlightedId === id, setHighlighted: (id) => { highlightedId = id }, registerItem(id, entry) { // Idempotent: the item $effect re-runs when registration itself // invalidates its deps, so a redundant write would ping-pong forever // (effect_update_depth_exceeded). The onSelect closure is fresh on every // run by construction, so compare data fields and swap the fn silently. const prev = allItems.get(id) if ( prev && prev.value === entry.value && prev.text === entry.text && prev.disabled === entry.disabled && prev.groupId === entry.groupId && (prev.keywords ?? []).join('\n') === (entry.keywords ?? []).join('\n') ) { prev.onSelect = entry.onSelect return } allItems.set(id, entry) if (entry.groupId) { let members = allGroups.get(entry.groupId) if (!members) { members = new SvelteSet<string>() allGroups.set(entry.groupId, members) } members.add(id) } }, unregisterItem(id) { if (!allItems.has(id)) return allItems.delete(id) if (highlightedId === id) highlightedId = null }, registerGroup(id) { if (!allGroups.has(id)) allGroups.set(id, new SvelteSet<string>()) }, unregisterGroup(id) { if (!allGroups.has(id)) return allGroups.delete(id) }, moveHighlight, selectHighlighted, selectItem, setListElement(el) { listEl = el }, }) </script> <div bind:this={ref} data-uipkge data-slot="command" class={cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/command/CommandDialog.svelte0.9 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface CommandDialogProps { open?: boolean onOpenChange?: (open: boolean) => void title?: string description?: string children?: Snippet } </script> <script lang="ts"> import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '$lib/components/ui/dialog' import Command from './Command.svelte' let { open = $bindable(false), onOpenChange, title = 'Command Palette', description = 'Search for a command to run...', children, }: CommandDialogProps = $props() </script> <Dialog bind:open {onOpenChange}> <DialogContent class="overflow-hidden p-0"> <DialogHeader class="sr-only"> <DialogTitle>{title}</DialogTitle> <DialogDescription>{description}</DialogDescription> </DialogHeader> <Command> {@render children?.()} </Command> </DialogContent> </Dialog>src/lib/components/ui/command/CommandEmpty.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandEmptyProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getCommandContext } from './context' let { class: className, children, ref = $bindable(null), ...restProps }: CommandEmptyProps = $props() const ctx = getCommandContext() const show = $derived(!!ctx.search && ctx.count === 0) </script> {#if show} <div bind:this={ref} data-uipkge data-slot="command-empty" class={cn('py-6 text-center text-sm', className)} {...restProps} > {@render children?.()} </div> {/if}src/lib/components/ui/command/CommandGroup.svelte1.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandGroupProps extends HTMLAttributes<HTMLDivElement> { heading?: string ref?: HTMLDivElement | null } let commandGroupIdCounter = 0 </script> <script lang="ts"> import { untrack } from 'svelte' import { cn } from '$lib/utils' import { getCommandContext, setCommandGroupContext } from './context' let { class: className, heading, children, ref = $bindable(null), ...restProps }: CommandGroupProps = $props() commandGroupIdCounter += 1 const id = `g${commandGroupIdCounter}` const ctx = getCommandContext() setCommandGroupContext(id) $effect(() => { // untrack: keep the register write from subscribing this effect to the // command's group map (effect_update_depth_exceeded). Registration is idempotent. untrack(() => ctx.registerGroup(id)) return () => ctx.unregisterGroup(id) }) const visible = $derived(ctx.isGroupVisible(id)) </script> <div bind:this={ref} data-uipkge data-slot="command-group" role="group" aria-label={heading} hidden={!visible} class={cn('text-foreground overflow-hidden p-1', className)} {...restProps} > {#if heading} <div data-uipkge data-slot="command-group-heading" class="text-muted-foreground px-2 py-1.5 text-xs font-medium"> {heading} </div> {/if} {@render children?.()} </div>src/lib/components/ui/command/CommandInput.svelte2.1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandInputProps extends HTMLAttributes<HTMLInputElement> { /** Focus the input on mount. Default false so inline embeds don't steal focus. */ autoFocus?: boolean ref?: HTMLInputElement | null } </script> <script lang="ts"> import { Search } from '@lucide/svelte' import { cn } from '$lib/utils' import { getCommandContext } from './context' let { class: className, autoFocus = false, ref = $bindable(null), onkeydown, ...restProps }: CommandInputProps = $props() const ctx = getCommandContext() $effect(() => { if (autoFocus) ref?.focus() }) type OnKeyDown = NonNullable<HTMLAttributes<HTMLInputElement>['onkeydown']> const handleKeyDown: OnKeyDown = (e) => { onkeydown?.(e) if (e.defaultPrevented) return if (e.key === 'ArrowDown') { e.preventDefault() ctx.moveHighlight(1) } else if (e.key === 'ArrowUp') { e.preventDefault() ctx.moveHighlight(-1) } else if (e.key === 'Enter') { e.preventDefault() ctx.selectHighlighted() } else if (e.key === 'Escape' && ctx.search) { e.preventDefault() ctx.setSearch('') } } </script> <div data-uipkge data-slot="command-input-wrapper" class="flex h-9 items-center gap-2 border-b px-3"> <Search class="text-muted-foreground size-4 shrink-0" aria-hidden="true" /> <input bind:this={ref} bind:value={() => ctx.search, (v) => ctx.setSearch(v)} role="combobox" aria-expanded="true" aria-controls={ctx.listId} aria-activedescendant={ctx.activeDescendant} autocomplete="off" autocorrect="off" spellcheck="false" data-uipkge data-slot="command-input" class={cn( 'placeholder:text-muted-foreground focus-visible:ring-ring/40 flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', className, )} {...restProps} onkeydown={handleKeyDown} /> </div>src/lib/components/ui/command/CommandItem.svelte2.9 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandItemProps extends HTMLAttributes<HTMLDivElement> { /** Unique value for the item. Used for selection and as the filter fallback. */ value?: string /** Extra search terms passed to a custom `filter` — mirrors cmdk item `keywords`. */ keywords?: string[] disabled?: boolean onSelect?: (value: string) => void ref?: HTMLDivElement | null } let commandItemIdCounter = 0 </script> <script lang="ts"> import { untrack } from 'svelte' import { cn } from '$lib/utils' import { getCommandContext, getCommandGroupContext } from './context' let { class: className, value: valueProp = '', keywords, disabled = false, onSelect, children, ref = $bindable(null), onclick, onmouseenter, ...restProps }: CommandItemProps = $props() commandItemIdCounter += 1 const id = `c${commandItemIdCounter}` const ctx = getCommandContext() const groupId = getCommandGroupContext() $effect(() => { const text = ref?.textContent ?? valueProp ?? '' // untrack: the register write must not subscribe this effect to the // command's item map (effect_update_depth_exceeded). Registration is idempotent. untrack(() => ctx.registerItem(id, { value: valueProp, text, disabled, groupId, keywords, onSelect: (v) => onSelect?.(v), }), ) return () => ctx.unregisterItem(id) }) const visible = $derived(ctx.isItemVisible(id)) const highlighted = $derived(ctx.isHighlighted(id)) type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> type OnMouseEnter = NonNullable<HTMLAttributes<HTMLDivElement>['onmouseenter']> const handleClick: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented || disabled) return ctx.selectItem(id) } const handleMouseEnter: OnMouseEnter = (e) => { onmouseenter?.(e) if (!disabled) ctx.setHighlighted(id) } </script> <div bind:this={ref} data-uipkge data-slot="command-item" role="option" id={`${ctx.listId}-item-${id}`} data-command-item-id={id} data-value={valueProp} data-highlighted={highlighted ? '' : undefined} data-disabled={disabled ? '' : undefined} aria-selected={highlighted} aria-disabled={disabled || undefined} hidden={!visible} class={cn( "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...restProps} onclick={handleClick} onmouseenter={handleMouseEnter} > {@render children?.()} </div>src/lib/components/ui/command/CommandList.svelte0.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandListProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getCommandContext } from './context' let { class: className, children, ref = $bindable(null), ...restProps }: CommandListProps = $props() const ctx = getCommandContext() $effect(() => { ctx.setListElement(ref) return () => ctx.setListElement(null) }) </script> <div bind:this={ref} data-uipkge data-slot="command-list" role="listbox" id={ctx.listId} aria-label="Commands" class={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)} {...restProps} > <div role="presentation"> {@render children?.()} </div> </div>src/lib/components/ui/command/CommandSeparator.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandSeparatorProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: CommandSeparatorProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="command-separator" role="separator" class={cn('bg-border -mx-1 h-px', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/command/CommandShortcut.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface CommandShortcutProps extends HTMLAttributes<HTMLElement> { ref?: HTMLElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: CommandShortcutProps = $props() </script> <span bind:this={ref} data-uipkge data-slot="command-shortcut" class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)} {...restProps} > {@render children?.()} </span>src/lib/components/ui/command/context.ts2.1 kB
import { getContext, setContext } from 'svelte' /** * Custom filter — mirrors cmdk `filter`. Return a positive number (or `true`) * to keep the item, `0` (or `false`) to hide it. Receives the item value, the * current search string, and the item's keywords. */ export type CommandFilter = (value: string, search: string, keywords?: string[]) => number | boolean export interface CommandItemRegistration { value: string text: string disabled: boolean groupId: string | undefined keywords: string[] | undefined onSelect: (value: string) => void } export interface CommandContextValue { readonly search: string setSearch: (search: string) => void readonly count: number readonly listId: string readonly activeDescendant: string | undefined isItemVisible: (id: string) => boolean isGroupVisible: (groupId: string) => boolean isHighlighted: (id: string) => boolean setHighlighted: (id: string | null) => void registerItem: (id: string, entry: CommandItemRegistration) => void unregisterItem: (id: string) => void registerGroup: (id: string) => void unregisterGroup: (id: string) => void moveHighlight: (direction: 1 | -1) => void selectHighlighted: () => void selectItem: (id: string) => void setListElement: (el: HTMLElement | null) => void } const COMMAND_CONTEXT_KEY = Symbol('uipkge-command') const COMMAND_GROUP_CONTEXT_KEY = Symbol('uipkge-command-group') export function setCommandContext(value: CommandContextValue): void { setContext(COMMAND_CONTEXT_KEY, value) } export function getCommandContext(): CommandContextValue { const value = getContext<CommandContextValue | undefined>(COMMAND_CONTEXT_KEY) if (!value) throw new Error('Command parts must be used inside <Command>') return value } export function setCommandGroupContext(groupId: string): void { setContext(COMMAND_GROUP_CONTEXT_KEY, groupId) } export function getCommandGroupContext(): string | undefined { return getContext<string | undefined>(COMMAND_GROUP_CONTEXT_KEY) } export function normalizeSearch(s: string): string { return s .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') }src/lib/components/ui/command/index.ts0.8 kB
export { default as Command, type CommandProps } from './Command.svelte' export { default as CommandDialog, type CommandDialogProps } from './CommandDialog.svelte' export { default as CommandEmpty, type CommandEmptyProps } from './CommandEmpty.svelte' export { default as CommandGroup, type CommandGroupProps } from './CommandGroup.svelte' export { default as CommandInput, type CommandInputProps } from './CommandInput.svelte' export { default as CommandItem, type CommandItemProps } from './CommandItem.svelte' export { default as CommandList, type CommandListProps } from './CommandList.svelte' export { default as CommandSeparator, type CommandSeparatorProps } from './CommandSeparator.svelte' export { default as CommandShortcut, type CommandShortcutProps } from './CommandShortcut.svelte' export type { CommandFilter } from './context'
Raw manifest:https://uipkge.dev/r/svelte/command.json