A dialog that is one search field over a list of commands.
On this page
A command palette is one search field over a list of commands, opened from anywhere with a shortcut. It is a dialog with a different interior: add command beside dialog, and put a command-search row, a listbox and an optional command-footer inside. Because it is a native <dialog>, a button opens it with command="show-modal", and the browser supplies the scrim, the focus trap and Esc.
Open the palette with the button, or with ⌘K or Ctrl K while focus is in the preview. Type to filter, move with the arrow keys and run a command with Enter.
The dialog opens and closes without script. The commandbehavior adds what a palette has beyond a dialog. It takes the dialog and returns the function that detaches it.
app.js
import{ command }from'@master/ui/behaviors/command.js'command(document.querySelector('.command'))document.getElementById('cmd-new').addEventListener('click',()=>{// Create the project.})
The shortcut.⌘K or CtrlK opens the palette from anywhere in the document and closes it again.
Filtering. Typing in command-input sets hidden on the options that do not match and makes the first match the active option. The listbox shows its empty state, and hides a group whose commands are all filtered out, by itself.
The active option has aria-selected="true" and is named by aria-activedescendant on the field. The arrow keys move it, and the palette has one from the start, so Enter has something to run.
Running a command.Enter clicks the active option, so an option that is a link navigates and a click listener on an option runs, as for a pointer. A click on an option, from Enter or the pointer, closes the palette. The behavior dispatches no event of its own: the click is the event.
Option
What it sets
shortcut
The key that opens and closes the palette with Command or Control. 'k' by default, false for none
Without the behavior the button still opens the dialog, the field takes text and Esc closes it. Nothing filters, and the arrow keys do not move through the list.
The command component renders the palette from a list of commands, attaches the behavior and tells the application when the palette opens and closes. Copy it into the project with npx master-ui add command; React, Svelte and Vue says how the components are built and used.
ui/command.tsx
'use client'import{ useCallback, useEffect, useId, useReducer, useRef,type ComponentProps,type ReactNode }from'react'import{ command }from'@master/ui/behaviors/command.js'import{ useBehavior }from'./use-behavior'exportinterfaceCommandItem{label:string/** A shortcut or a hint at the end of the row. */hint?:ReactNode/** The caption of the group the command belongs to. Commands of one group are written together. */group?:string/** Where the command goes. Without it, `onSelect` runs. */href?:stringonSelect?:()=>voiddisabled?:boolean}exportinterfaceCommandPropsextendsOmit<ComponentProps<'dialog'>,'open'|'onSelect'>{items:CommandItem[]open?:booleanonOpenChange?:(open:boolean)=>void/** The key that opens and closes the palette with Command or Control. `'k'` by default, `false` for none. */shortcut?:string|false/** False when `items` are already the matches; `onSearch` then says what was typed. */filter?:booleanonSearch?:(text:string)=>void/** The name of the search field, and the placeholder it shows. */searchLabel:stringplaceholder?:string/** The name of the list of commands. */listLabel:stringempty?:ReactNode}/** A command palette: a native dialog over a search field and a list. Name the dialog with `aria-label`. */exportfunctionCommand({ items, open, onOpenChange, shortcut ='k', filter =true, onSearch, searchLabel, placeholder, listLabel, empty, className,...dialog }:CommandProps){const id =useId()const element =useRef<HTMLDialogElement|null>(null)const attach =useBehavior(command,{ shortcut, filter })const ref =useCallback((root:HTMLDialogElement|null)=>{ element.current= rootreturnattach(root)??undefined},[attach])const[tick, again]=useReducer((count:number)=> count +1,0)const heard =useRef(onOpenChange)/** What the owner knows: the last state it set or was told. */const known =useRef(false)useEffect(()=>{ heard.current= onOpenChange })useEffect(()=>{const root = element.currentif(!root || open ===undefined)return known.current= openif(open &&!root.open) root.showModal()elseif(!open && root.open) root.close()},[open, tick])useEffect(()=>{const root = element.currentif(!root)returnconst controller =newAbortController()// The shortcut, Esc and a command that ran all open or close the dialog without asking: say so.constreport=()=>{if(root.open!== known.current) heard.current?.(known.current= root.open)again()}for(const type of['toggle','close']) root.addEventListener(type, report,{signal: controller.signal})return()=> controller.abort()},[])const groups =[...newSet(items.map(item => item.group))]constoption=(item:CommandItem, at:number)=> item.href?<aclassName="listbox-option"role="option"key={at}href={item.href}aria-disabled={item.disabled||undefined}>{item.label}{item.hint!==undefined&&<spanclassName="listbox-end">{item.hint}</span>}</a>:<divclassName="listbox-option"role="option"key={at}onClick={item.onSelect}aria-disabled={item.disabled||undefined}>{item.label}{item.hint!==undefined&&<spanclassName="listbox-end">{item.hint}</span>}</div>return(<dialogclassName={className ?`dialog command ${className}`:'dialog command'}ref={ref}{...{closedby:'any'}}{...dialog}><labelclassName="command-search"><inputclassName="command-input"type="search"role="combobox"aria-expanded="true"aria-controls={`${id}list`}aria-autocomplete="list"aria-label={searchLabel}autoComplete="off"placeholder={placeholder}autoFocusonInput={event =>onSearch?.(event.currentTarget.value)}/></label><divclassName="listbox command-list"id={`${id}list`}role="listbox"aria-label={listLabel}>{groups.map((group, index)=> group ===undefined? items.map((item, at)=> item.group===undefined&&option(item, at)):(<divclassName="listbox-group"role="group"aria-labelledby={`${id}group${index}`}key={group}><divclassName="listbox-label"id={`${id}group${index}`}>{group}</div>{items.map((item, at)=> item.group=== group &&option(item, at))}</div>))}{empty !==undefined&&<divclassName="listbox-empty">{empty}</div>}</div></dialog>)}
ui/Command.svelte
<scriptlang="ts"module>importtype{ HTMLDialogAttributes }from'svelte/elements'exportinterfaceCommandItem{label:string/** A shortcut or a hint at the end of the row. */hint?:string/** The caption of the group the command belongs to. Commands of one group are written together. */group?:string/** Where the command goes. Without it, `onSelect` runs. */href?:stringonSelect?:()=>voiddisabled?:boolean}exportinterfaceCommandPropsextendsOmit<HTMLDialogAttributes,'open'|'children'>{items:CommandItem[]/** Whether the palette is open. Bind it, or leave it out and the shortcut opens it. */open?:booleanonOpenChange?:(open:boolean)=>void/** The key that opens and closes the palette with Command or Control. `'k'` by default, `false` for none. */shortcut?:string|false/** False when `items` are already the matches; `onSearch` then says what was typed. */filter?:booleanonSearch?:(text:string)=>void/** The name of the search field, and the placeholder it shows. */searchLabel:stringplaceholder?:string/** The name of the list of commands. */listLabel:stringempty?:string}</script><scriptlang="ts">importtype{ Attachment }from'svelte/attachments'import{ command }from'@master/ui/behaviors/command.js'import{ behavior }from'./behavior.svelte.js'let{ items, open =$bindable(), onOpenChange, shortcut ='k', filter =true, onSearch, searchLabel, placeholder, listLabel, empty,class: className,...dialog }:CommandProps=$props()const id =$props.id()const attach =behavior(command,()=>({ shortcut, filter }))const groups =$derived([...newSet(items.map(item => item.group))])/** What the owner knows: the last state it set or was told. */let known =falseconstpresence:Attachment<HTMLDialogElement>=(root)=>{const controller =newAbortController()// The shortcut, Esc and a command that ran all open or close the dialog without asking: say so.constreport=()=>{if(root.open=== known)return open = known = root.openonOpenChange?.(known)}for(const type of['toggle','close']) root.addEventListener(type, report,{signal: controller.signal})$effect(()=>{if(open ===undefined)return known = openif(open &&!root.open) root.showModal()elseif(!open && root.open) root.close()})return()=> controller.abort()}</script>{#snippetoption(item: CommandItem)}<!-- The behavior writes `aria-selected` on the option the keys are on. --><!-- svelte-ignore a11y_role_has_required_aria_props -->{#if item.href}<aclass="listbox-option"role="option"href={item.href}aria-disabled={item.disabled||undefined}>{item.label}{#if item.hint!==undefined}<spanclass="listbox-end">{item.hint}</span>{/if}</a>{:else}<!-- Focus stays in the field, and Enter there clicks the option. --><!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus --><divclass="listbox-option"role="option"onclick={item.onSelect}aria-disabled={item.disabled||undefined}>{item.label}{#if item.hint!==undefined}<spanclass="listbox-end">{item.hint}</span>{/if}</div>{/if}{/snippet}<!--@component A command palette: a native dialog over a search field and a list. Name the dialog with `aria-label`.--><dialogclass={['dialog','command', className]}closedby="any"{@attach attach}{@attach presence}{...dialog}><labelclass="command-search"><!-- The dialog gives the field focus as it opens. --><!-- svelte-ignore a11y_autofocus --><inputclass="command-input"type="search"role="combobox"aria-expanded="true"aria-controls="{id}list"aria-autocomplete="list"aria-label={searchLabel}autocomplete="off"{placeholder}autofocusoninput={event =>onSearch?.(event.currentTarget.value)}/></label><divclass="listbox command-list"id="{id}list"role="listbox"aria-label={listLabel}>{#each groups as group, index (group)}{#if group ===undefined}{#each items.filter(item => item.group===undefined)as item}{@renderoption(item)}{/each}{:else}<divclass="listbox-group"role="group"aria-labelledby="{id}group{index}"><divclass="listbox-label"id="{id}group{index}">{group}</div>{#each items.filter(item => item.group=== group)as item}{@renderoption(item)}{/each}</div>{/if}{/each}{#if empty !==undefined}<divclass="listbox-empty">{empty}</div>{/if}</div></dialog>
ui/Command.vue
<scriptlang="ts">exportinterfaceCommandItem{label:string/** A shortcut or a hint at the end of the row. */hint?:string/** The caption of the group the command belongs to. Commands of one group are written together. */group?:string/** Where the command goes. Without it, `onSelect` runs. */href?:stringonSelect?:()=>voiddisabled?:boolean}</script><scriptsetuplang="ts">import{ computed, h, nextTick, onMounted, useId, useTemplateRef, watch,type FunctionalComponent }from'vue'import{ command }from'@master/ui/behaviors/command.js'import{ useBehavior }from'./use-behavior'defineOptions({inheritAttrs:false})const{ items, shortcut ='k', filter =true}=defineProps<{items:CommandItem[]/** The key that opens and closes the palette with Command or Control. `'k'` by default, `false` for none. */shortcut?:string|false/** False when `items` are already the matches; `search` then says what was typed. */filter?:boolean/** The name of the search field, and the placeholder it shows. */searchLabel:stringplaceholder?:string/** The name of the list of commands. */listLabel:stringempty?:string}>()/** Whether the palette is open: `v-model:open`. */const open =defineModel<boolean>('open')const emit =defineEmits<{/** What was typed in the search field. */search:[text:string]}>()/* A command palette: a native dialog over a search field and a list. Name the dialog with `aria-label`. */const id =useId()const root =useTemplateRef<HTMLDialogElement>('root')useBehavior(root, command,()=>({ shortcut, filter }))/** What the owner knows: the last state it set or was told. */let known =falseconstsync=()=>{const dialog = root.valueif(!dialog || open.value===undefined)return known = open.valueif(open.value&&!dialog.open) dialog.showModal()elseif(!open.value&& dialog.open) dialog.close()}// The shortcut, Esc and a command that ran all open or close the dialog without asking: say so.constreport=()=>{const dialog = root.valueif(!dialog)returnif(dialog.open!== known) open.value= known = dialog.openvoidnextTick(sync)}onMounted(sync)watch(open, sync,{flush:'post'})const groups =computed(()=>[...newSet(items.map(item => item.group))])constRow:FunctionalComponent<{item:CommandItem}>=({ item })=>{const content =[item.label, item.hint!==undefined?h('span',{class:'listbox-end'}, item.hint):undefined]return item.href?h('a',{'class':'listbox-option','role':'option','href': item.href,'aria-disabled': item.disabled||undefined}, content):h('div',{'class':'listbox-option','role':'option','onClick':()=> item.onSelect?.(),'aria-disabled': item.disabled||undefined}, content)}Row.props=['item']</script><template><dialogclass="dialog command"closedby="any"ref="root"v-bind="$attrs"@toggle="report"@close="report"><labelclass="command-search"><inputclass="command-input"type="search"role="combobox"aria-expanded="true":aria-controls="`${id}-list`"aria-autocomplete="list":aria-label="searchLabel"autocomplete="off":placeholder="placeholder"autofocus@input="emit('search',($event.targetasHTMLInputElement).value)"></label><divclass="listbox command-list":id="`${id}-list`"role="listbox":aria-label="listLabel"><templatev-for="(group, index)in groups":key="group ??''"><templatev-if="group ===undefined"><templatev-for="(item, at)in items":key="at"><Rowv-if="item.group===undefined":item="item"/></template></template><divv-elseclass="listbox-group"role="group":aria-labelledby="`${id}-group-${index}`"><divclass="listbox-label":id="`${id}-group-${index}`">{{ group }}</div><templatev-for="(item, at)in items":key="at"><Rowv-if="item.group=== group":item="item"/></template></div></template><divv-if="empty !==undefined"class="listbox-empty">{{ empty }}</div></div></dialog></template>
Beside dialog, on the same dialog element: the palette's interior without padding, 36rem wide, placed near the top of the viewport so the field stays where it is while the list changes length
command-search
The field row, on a label so the whole row focuses the input. It holds an icon, the command-input and an optional hint
command-input
The bare search input, with role="combobox" and aria-controls pointing at the list. It has no material of its own: the dialog is its surface
command-list
On the listbox of results: it takes the remaining height and scrolls inside the dialog
/* * Command palette: a dialog that is one search field over a listbox of commands. * It is the dialog component with a different interior: no padding, a large quiet field on a hairline, a * list that takes the remaining height, and a footer of key hints. It opens near the top of the viewport, * so the field stays where it is while the list under it grows and shrinks. */@layer components {.dialog.command{--dialog-width:36rem;--dialog-radius:1rem;gap:0;max-height:min(28rem,calc(100dvh-6rem));margin-top:min(18vh,9rem);padding:0;}/* The field row: an icon, the input and an optional hint, as one label so the whole row focuses the input. */.command-search{--icon-size:1.125rem;display:flex;flex:none;align-items:center;gap:.75rem;min-height:3.25rem;padding:01rem;color:var(--color-text-muted);box-shadow:inset0-1px0var(--color-line-hair);cursor:text;/* A painted hairline is dropped with every other shadow: a real one parts the field from the list. */@media(forced-colors:active){border-bottom:1pxsolid CanvasText;}}/* The input has no material of its own: the dialog is its surface. */.command-input{flex:110;align-self:stretch;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--color-text-strong);font:4001rem/1.5var(--font-family-sans);font-feature-settings:inherit;caret-color:var(--color-text-strong);appearance:none;&::placeholder{color:var(--color-text-muted);opacity:1;}/* With no matching option, the field must still identify keyboard focus in system colors. */@media(forced-colors:active){&:focus-visible{outline:2pxsolid Highlight;outline-offset:-2px;}}&::-webkit-search-cancel-button{display:none;}}/* The results: a listbox that scrolls inside the dialog, a little further in than a floating one. */.command-list{--listbox-inset:6px;flex:11auto;min-height:0;}/* Key hints under the list. */.command-footer{display:flex;flex:none;flex-wrap:wrap;align-items:center;gap:.375rem1rem;padding:.5rem.875rem;color:var(--color-text-muted);box-shadow:inset01px0var(--color-line-hair);font-size:.75rem;@media(forced-colors:active){border-top:1pxsolid CanvasText;}}/* One hint: keys, then what they do. */.command-hint{display:inline-flex;align-items:center;gap:.375rem;}}