Default
Groups, separators, keywords and a disabled item. Navigate with arrow keys, ctrl+n/j/p/k, Home and End; select with Enter.
Cmdk::Root(label: 'Command Menu', loop: @loop, vim_bindings: @vim_bindings,
disable_pointer_selection: @disable_pointer_selection, should_filter: @should_filter,
class: 'cmdk-vercel w-160 max-w-full') do
Cmdk::Input(placeholder: @placeholder)
Cmdk::List() do
Cmdk::Empty() { 'No results found.' }
Cmdk::Group(heading: 'Suggestions') do
Cmdk::Item(value: 'Linear', keywords: %w[issue tracker]) { item('๐', 'Linear') }
Cmdk::Item(value: 'Figma', keywords: %w[design]) { item('๐จ', 'Figma') }
Cmdk::Item(value: 'Slack', keywords: %w[chat team]) { item('๐ฌ', 'Slack') }
Cmdk::Item(value: 'YouTube', keywords: %w[video]) { item('๐บ', 'YouTube') }
Cmdk::Item(value: 'Raycast', keywords: %w[launcher]) { item('๐', 'Raycast') }
end
Cmdk::Separator()
Cmdk::Group(heading: 'Settings') do
Cmdk::Item(value: 'Change Theme', keywords: %w[appearance]) { item('๐', 'Change Theme') }
Cmdk::Item(value: 'Admin Settings', disabled: true) { item('๐', 'Admin Settings (disabled)') }
end
end
enddef initialize(placeholder: 'What do you need?', loop: true, vim_bindings: true,
disable_pointer_selection: false, should_filter: true)
@placeholder = placeholder
@loop = loop
@vim_bindings = vim_bindings
@disable_pointer_selection = disable_pointer_selection
@should_filter = should_filter
end
def item(icon, text)
span(class: 'text-base', aria_hidden: 'true') { icon }
span { text }
endPlain items
No groups; when value: is omitted it is inferred from the rendered text content.
Cmdk::Root(label: 'Fruits', class: 'cmdk-vercel w-160 max-w-full') do
Cmdk::Input(placeholder: 'Search fruit...')
Cmdk::List() do
Cmdk::Empty() { 'No results found.' }
%w[Apple Banana Cherry Grape Orange Peach].each do |fruit|
Cmdk::Item() { fruit } # value inferred from text content
end
end
endForce mount
Type 'zzz' and watch force-mounted entries ignore filtering.
Cmdk::Root(label: 'Force mount', class: 'cmdk-vercel w-160 max-w-full') do
Cmdk::Input(placeholder: "Type 'zzz': Help stays visible")
Cmdk::List() do
Cmdk::Empty() { 'No results found.' }
Cmdk::Group(heading: 'Results') do
Cmdk::Item() { 'Open Project' }
Cmdk::Item() { 'Close Project' }
end
Cmdk::Group(heading: 'Always here', force_mount: true) do
Cmdk::Item(value: 'Help') { 'โ Help (force mounted)' }
end
Cmdk::Separator(always_render: true)
Cmdk::Item(value: 'Quit', force_mount: true) { '๐ช Quit (force mounted)' }
end
endScoped search
An extension over the vanilla filter: narrow the search to a scope you pick, with groups that stay hidden until their scope is active.
/to pick a scope, Enter to pin it as a pill, Backspace on empty input to leave. Typing it out (/fruits ) works too.div(class: 'flex w-160 max-w-full flex-col gap-3') do
div(class: 'flex flex-wrap items-center gap-x-2 gap-y-1 text-xs demo-hint') do
plain 'Type'
code(class: 'demo-chip') { '/' }
plain 'to pick a scope, Enter to pin it as a pill, Backspace on empty input to leave. '
plain 'Typing it out ('
code(class: 'demo-chip') { '/fruits ' }
plain ') works too.'
end
Cmdk::Root(label: 'Scoped search', scopes: %w[fruits doc], class: 'cmdk-vercel w-full') do
div(class: 'cmdk-search-row') do
Cmdk::Input(placeholder: "Search, or type '/' for scopesโฆ")
end
Cmdk::List() do
Cmdk::Empty() { 'No results found.' }
Cmdk::Group(heading: 'Jump to') do
Cmdk::Item(value: 'fruits', enters_scope: 'fruits', keywords: %w[apple banana orange]) { '๐ Search fruitsโฆ' }
Cmdk::Item(value: 'doc', enters_scope: 'doc', keywords: %w[files pages]) { '๐ Search documentsโฆ' }
end
Cmdk::Group(heading: 'Actions') do
Cmdk::Item() { 'โ New Issue' }
Cmdk::Item() { '๐ Search Everything' }
end
Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true) do
Cmdk::Item() { '๐ Apple' }
Cmdk::Item() { '๐ Banana' }
Cmdk::Item() { '๐ Orange' }
end
Cmdk::Group(heading: 'Documents', scope: 'doc') do
Cmdk::Item() { '๐ README' }
Cmdk::Item() { '๐ Architecture Notes' }
end
end
end
script { raw safe(<<~JS) }
document.addEventListener('cmdk-scope-change', (e) => {
// In a real app this is where you would kick off a server-backed
// search, e.g. frame.src = `/search/fruits?q=${e.detail.query}`
console.log('cmdk-scope-change', e.detail)
})
JS
endLoading
Render Cmdk::Loading while fetching asynchronous items.
Cmdk::Root(label: 'Loading', class: 'cmdk-vercel w-160 max-w-full') do
Cmdk::Input(placeholder: 'Fetching results...')
Cmdk::List() do
Cmdk::Loading(progress: @progress) { 'Hang on, loading resultsโฆ' }
end
enddef initialize(progress: 50)
@progress = progress
endEmpty state
Cmdk::Empty shows whenever there are no results.
Cmdk::Root(label: 'Empty', class: 'cmdk-vercel w-160 max-w-full') do
Cmdk::Input(placeholder: 'No items were rendered at all')
Cmdk::List() do
Cmdk::Empty() { 'No results found.' }
end
endEvent wiring
Everything you would wire as a callback arrives as a bubbling DOM event. Interact and watch the log.
div(class: 'flex w-160 max-w-full flex-col gap-4', data: { events_demo: '' }) do
render Menu.new
pre(data: { events_log: '' }, class: 'demo-panel h-32 overflow-y-auto p-3 text-xs')
script { raw safe(<<~JS) }
const scope = document.currentScript.closest('[data-events-demo]')
const log = scope.querySelector('[data-events-log]')
const root = scope.querySelector('[cmdk-root]')
for (const type of ['cmdk-item-select', 'cmdk-value-change', 'cmdk-search-change']) {
root.addEventListener(type, (e) => {
log.textContent = `${type.padEnd(18)} ${JSON.stringify(e.detail)}\\n` + log.textContent
})
}
JS
endStimulus controller
The same events, wired through the optional Stimulus base controller: extend CmdkController and override its hooks (itemSelected, valueChanged, searchChanged, scopeChanged).
div(data: { controller: 'palette' }, class: 'flex w-160 max-w-full flex-col gap-4') do
Cmdk::Root(label: 'Stimulus', loop: true, class: 'cmdk-vercel w-full') do
Cmdk::Input(placeholder: 'Wired with a Stimulus controllerโฆ')
Cmdk::List() do
Cmdk::Empty() { 'No results found.' }
Cmdk::Group(heading: 'Suggestions') do
Cmdk::Item(value: 'Linear') { '๐ Linear' }
Cmdk::Item(value: 'Figma') { '๐จ Figma' }
Cmdk::Item(value: 'Slack') { '๐ฌ Slack' }
end
end
end
pre(data: { palette_log: '' }, class: 'demo-panel h-32 overflow-y-auto p-3 text-xs')
endimport { Application } from '@hotwired/stimulus'
import CmdkController from 'cmdk_controller'
// Extend the gem's base controller and override only the hooks you need.
class PaletteController extends CmdkController {
itemSelected(event) { this.log(`selected ${event.detail.value}`) }
valueChanged(event) { this.log(`highlighted ${event.detail.value}`) }
searchChanged(event) { this.log(`search ${JSON.stringify(event.detail)}`) }
log(line) {
const out = this.element.querySelector('[data-palette-log]')
out.textContent = line + '\n' + out.textContent
}
}
Application.start().register('palette', PaletteController)
CRT terminal
A look built from scratch: scanlines, phosphor glow, inverted selection. Plain CSS against the cmdk attribute contract.
Cmdk::Root(label: 'Terminal', loop: true, class: 'cmdk-terminal w-160 max-w-full') do
Cmdk::Input(placeholder: 'type a command_')
Cmdk::List() do
Cmdk::Empty() { 'command not found' }
Cmdk::Group(heading: 'processes') do
Cmdk::Item(value: 'deploy production', hint: 'execute', kbd: 'โ') { plain 'bin/deploy --production' }
Cmdk::Item(value: 'tail logs', hint: 'execute', kbd: 'โ') { plain 'tail -f log/production.log' }
Cmdk::Item(value: 'rails console', hint: 'execute', kbd: 'โ') { plain 'bin/rails console' }
Cmdk::Item(value: 'run tests', hint: 'execute', kbd: 'โ') { plain 'bundle exec rake test' }
end
Cmdk::Separator()
Cmdk::Group(heading: 'danger zone') do
Cmdk::Item(value: 'drop database', disabled: true) { plain 'bin/rails db:drop' }
end
end
Cmdk::Footer() do
span { 'guest@phlex-cmdk' }
div('cmdk-footer-hint' => '')
end
end/* >>> cmdk-terminal */
/* A CRT terminal theme, written from scratch against the cmdk attribute
contract: phosphor green, scanlines, inverted selection, blinking caret. */
.cmdk-terminal[cmdk-root] {
position: relative;
overflow: hidden;
padding: 8px;
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
background: #04100a;
border: 1px solid #14532d;
border-radius: 6px;
color: #4ade80;
box-shadow:
0 0 50px rgb(74 222 128 / 0.18),
inset 0 0 90px rgb(74 222 128 / 0.05);
}
.cmdk-terminal[cmdk-root]::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(0deg, rgb(0 0 0 / 0.22) 0 1px, transparent 1px 3px);
}
.cmdk-terminal [cmdk-input] {
width: 100%;
border: none;
border-bottom: 1px dashed #14532d;
outline: none;
/* The โบ prompt is drawn in CSS so it shows on any terminal-themed menu,
not only the one example that wrapped the input in markup. */
background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234ade80' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 6 15 12 9 18'/%3E%3C/svg%3E") no-repeat 9px 50%;
background-size: 13px;
padding: 10px 8px 12px 30px;
font: inherit;
font-size: 14px;
letter-spacing: 0.03em;
color: #bbf7d0;
caret-color: #4ade80;
}
.cmdk-terminal [cmdk-input]::placeholder {
color: rgb(74 222 128 / 0.35);
}
.cmdk-terminal [cmdk-list] {
height: min(330px, calc(var(--cmdk-list-height) + 6px));
max-height: 330px;
overflow-y: auto;
overscroll-behavior: contain;
transition: height 100ms ease;
padding-top: 6px;
}
.cmdk-terminal [cmdk-group-heading] {
padding: 12px 8px 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.25em;
color: rgb(74 222 128 / 0.5);
user-select: none;
}
.cmdk-terminal [cmdk-group-heading]::before {
content: '# ';
}
.cmdk-terminal [cmdk-item] {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 8px;
font-size: 14px;
color: #86efac;
cursor: pointer;
user-select: none;
}
.cmdk-terminal [cmdk-item]::before {
content: 'โฏ';
flex: none;
width: 14px;
text-align: center;
color: #4ade80;
opacity: 0; /* the marker column reserves space; only the selected row shows it */
}
.cmdk-terminal [cmdk-item][data-selected='true'] {
background: rgb(74 222 128 / 0.14);
color: #dcfce7;
text-shadow: 0 0 10px rgb(74 222 128 / 0.55);
}
.cmdk-terminal [cmdk-item][data-selected='true']::before {
opacity: 1;
}
.cmdk-terminal [cmdk-item][data-disabled='true'] {
color: rgb(74 222 128 / 0.3);
cursor: not-allowed;
text-decoration: line-through;
}
.cmdk-terminal [cmdk-separator] {
height: 0;
border-top: 1px dashed #14532d;
margin: 8px;
}
.cmdk-terminal [cmdk-empty] {
display: flex;
align-items: center;
justify-content: center;
height: 56px;
font-size: 13px;
color: rgb(74 222 128 / 0.45);
}
.cmdk-terminal [cmdk-empty]::before {
content: 'sh: ';
}
.cmdk-terminal [cmdk-footer] {
display: flex;
align-items: center;
gap: 8px;
margin: 6px -8px -8px;
padding: 8px 16px;
border-top: 1px dashed #14532d;
font-size: 12px;
color: rgb(74 222 128 / 0.55);
}
.cmdk-terminal [cmdk-footer-hint] {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
color: #86efac;
}
.cmdk-terminal [cmdk-footer-hint] kbd {
padding: 1px 6px;
font: inherit;
font-size: 11px;
border: 1px solid #14532d;
border-radius: 3px;
background: rgb(74 222 128 / 0.08);
color: #4ade80;
}
.cmdk-terminal [cmdk-footer-hint][data-empty] {
display: none;
}Neo-brutalism
No stylesheet at all: thick borders, hard shadows and pressed-button selection, built entirely from Tailwind utilities and data-[...] variants on the components.
Cmdk::Root(label: 'Brutal Menu', loop: true, class: <<~CLASSES.split.join(' ')) do
w-160 max-w-full border-4 border-black bg-amber-50 p-3
shadow-[10px_10px_0_rgb(0,0,0)]
CLASSES
Cmdk::Input(placeholder: 'TYPE SOMETHING LOUD', class: <<~CLASSES.split.join(' '))
w-full border-4 border-black bg-white px-3 py-2 text-base font-extrabold uppercase
tracking-wide text-black outline-none placeholder:text-neutral-400
focus:bg-yellow-200 focus:shadow-[4px_4px_0_rgb(0,0,0)]
CLASSES
Cmdk::List(class: 'h-[min(360px,calc(var(--cmdk-list-height)+16px))] max-h-[360px] overflow-y-auto overscroll-contain pt-3 pb-1 transition-[height] duration-100') do
Cmdk::Empty(class: 'mx-1 flex h-16 items-center justify-center border-4 border-dashed border-black text-sm font-black uppercase text-black') do
plain 'absolutely nothing'
end
Cmdk::Group(heading: 'loud actions', class: group_classes) do
Cmdk::Item(value: 'Ship It', hint: 'No Regrets', kbd: 'โต', class: item_classes) { entry('๐ข', 'Ship It') }
Cmdk::Item(value: 'Make It Pop', hint: 'More Contrast', kbd: 'โต', class: item_classes) { entry('๐จ', 'Make It Pop') }
Cmdk::Item(value: 'Big Red Button', hint: 'Press It', kbd: 'โต', class: item_classes) { entry('๐ด', 'Big Red Button') }
end
Cmdk::Separator(class: 'mx-1 my-3 h-1 bg-black')
Cmdk::Group(heading: 'regrets', class: group_classes) do
Cmdk::Item(value: 'Undo Everything', disabled: true, class: item_classes) { entry('โฉ๏ธ', 'Undo Everything') }
end
end
Cmdk::Footer(class: '-m-3 mt-3 flex items-center gap-2 border-t-4 border-black bg-fuchsia-300 px-4 py-2 text-xs font-black uppercase text-black') do
span { 'brutal.exe' }
div('cmdk-footer-hint' => '', class: <<~CLASSES.split.join(' '))
ml-auto flex items-center gap-2 data-empty:hidden
[&_kbd]:border-2 [&_kbd]:border-black [&_kbd]:bg-white [&_kbd]:px-1.5
[&_kbd]:shadow-[2px_2px_0_rgb(0,0,0)]
CLASSES
end
enddef group_classes
<<~CLASSES.split.join(' ')
[&_[cmdk-group-heading]]:m-2 [&_[cmdk-group-heading]]:inline-block
[&_[cmdk-group-heading]]:-rotate-2 [&_[cmdk-group-heading]]:bg-black
[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-0.5
[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-black
[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-widest
[&_[cmdk-group-heading]]:text-amber-50
CLASSES
end
def item_classes
<<~CLASSES.split.join(' ')
mx-1 mb-2 flex min-h-11 cursor-pointer items-center gap-2 border-2 border-black bg-white
px-3 text-sm font-bold text-black select-none shadow-[4px_4px_0_rgb(0,0,0)]
transition-[transform,box-shadow,background-color] duration-75
data-[selected=true]:translate-x-[2px] data-[selected=true]:translate-y-[2px]
data-[selected=true]:bg-yellow-300 data-[selected=true]:shadow-[2px_2px_0_rgb(0,0,0)]
data-[disabled=true]:cursor-not-allowed data-[disabled=true]:bg-neutral-200
data-[disabled=true]:text-neutral-500 data-[disabled=true]:shadow-none
data-[disabled=true]:line-through
CLASSES
end
def entry(icon, text)
span(class: 'text-base', aria_hidden: 'true') { icon }
span { text }
end