⌘K for Phlex

A feature-parity port of the cmdk React command menu, built for Phlex. Two pieces, and that is the whole dependency surface:

The only runtime dependency is phlex. Everything past that is your choice:

The runtime uses event delegation and MutationObservers rather than mounting, so it survives Turbo navigation and morphing with no per-page setup, and items appended later (Turbo Streams, your own DOM writes) are registered, filtered and sorted automatically.

Install

# Gemfile
gem 'phlex-cmdk'

Serve or bundle the runtime once per page. Its path is exposed as Cmdk.javascript_path (copy it into your assets, pin it in your importmap, or serve it directly):

<script type="module" src="/cmdk.js"></script> <!-- auto-starts on import -->

The components are unstyled; optionally start from the shipped themes (Cmdk.stylesheet_path - see Styling).

With Rails (importmap + Propshaft)

Serve the gem assets straight from the gem - no copying:

# config/initializers/cmdk.rb
Rails.application.config.assets.paths << File.dirname(Cmdk.javascript_path)
Rails.application.config.assets.paths << File.dirname(Cmdk.stylesheet_path)
# config/importmap.rb
pin "cmdk", to: "cmdk.js"
pin "cmdk_controller", to: "cmdk_controller.js" # optional Stimulus base controller
// app/javascript/application.js
import "cmdk"
<%# layout - optional ready-made themes %>
<%= stylesheet_link_tag "cmdk_themes", "data-turbo-track": "reload" %>

With Tailwind

Everything composes with a Tailwind v4 setup out of the box: components accept class: attributes like any Phlex element, the runtime needs no build step, and the shipped themes are plain CSS you can import into your input stylesheet:

@import 'tailwindcss';
@import '../path/to/cmdk_themes.css'; /* copied from Cmdk.stylesheet_path */
Cmdk::Root(class: 'cmdk-vercel w-full max-w-xl') do ... end

No @source configuration is needed for the gem - its components emit no Tailwind utilities of their own.

Do I need tailwind-merge / cn()? No, by design. That pattern exists because React component libraries ship utility-class defaults which consumers override in the same class attribute; which one wins depends on stylesheet order, so tailwind-merge rewrites the string. cmdk-phlex components emit no utility classes at all - your class: passes through untouched, so there is nothing to conflict with. The shipped themes live in @layer components while Tailwind’s utilities layer comes later, so a utility on a component (Cmdk::Item(class: 'pt-3')) overrides the theme without any merging - and without Tailwind, your own unlayered CSS overrides the layered themes just the same. If you build your own variant components with conditional utility defaults on top, that is regular Phlex + Tailwind territory: reach for the tailwind_merge gem exactly where you would reach for cn(). (Heads-up: Tailwind’s preflight resets break native <dialog> centering; the runtime ships zero-specificity defaults that handle this - see Dialog.)

Use

class CommandMenu < Phlex::HTML
  def view_template
    Cmdk::Root(label: 'Global Command Menu', loop: true) do
      Cmdk::Input(placeholder: 'What do you need?')
      Cmdk::List() do
        Cmdk::Empty() { 'No results found.' }

        Cmdk::Group(heading: 'Suggestions') do
          Cmdk::Item(value: 'linear', keywords: %w[issue tracker]) { 'Linear' }
          Cmdk::Item(value: 'figma', disabled: true) { 'Figma' }
        end

        Cmdk::Separator()
        Cmdk::Item(href: '/settings') { 'Settings' } # Turbo.visit on select
      end
    end
  end
end

Every interaction is a bubbling DOM event, so listen on the root, the document, or via a Stimulus action (data-action="cmdk-item-select->palette#run"):

root.addEventListener('cmdk-item-select', (e) => run(e.detail.value)) // cancelable
root.addEventListener('cmdk-value-change', (e) => preview(e.detail.value))
root.addEventListener('cmdk-search-change', (e) => e.detail.search)

Dialog

Cmdk::Dialog(label: 'Command Menu', hotkey: 'k') do  # ⌘K / ctrl+K toggles it
  Cmdk::Input()
  Cmdk::List() { ... }
end

Renders a native <dialog cmdk-dialog>: Escape and backdrop clicks close it, and Cmdk.openDialog(el) / Cmdk.closeDialog(el) toggle it programmatically. Style the backdrop with dialog[cmdk-dialog]::backdrop (replaces Radix’s [cmdk-overlay]).

By default the dialog renders as a top-third, horizontally centered palette; on viewports ≤640px it becomes a top-anchored, full-width sheet (the GitHub/Jira pattern - the software keyboard owns the bottom of the screen, so the input belongs at the top), sized with dvh units so dynamic viewports behave. CSS resets (e.g. Tailwind preflight’s universal margin: 0) break native <dialog> centering, so the runtime injects these defaults with zero specificity (:where())

dialog[cmdk-dialog] { margin-top: 30vh; }   /* overrides the default placement */

Other mobile defaults: the shipped themes bump the input to 16px under 640px (prevents iOS Safari’s focus zoom), Cmdk::Input sets enterkeyhint="go" for the mobile keyboard, and touch-move over items doesn’t drag the selection around while scrolling (only real pointer hover selects).

cmdk deliberately keeps its filter vanilla; modes like fruit: <query> are userland. This port gives you both levels:

Declarative scopes - declare them on the root, tag items or groups, and offer scope-entry items for the picker:

Cmdk::Root(label: 'Search', scopes: %w[fruits doc]) do
  div(class: 'cmdk-search-row') { Cmdk::Input() }     # flex row hosts the pill
  Cmdk::List() do
    Cmdk::Item(enters_scope: 'fruits') { 'Search fruits…' }
    Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true) { ... }
    Cmdk::Group(heading: 'Docs',   scope: 'doc') { ... }
  end
end

The flow follows the Linear/Slack/Raycast pattern (and cmdk’s own “pages” recipe):

The root mirrors the state as data-cmdk-active-scope="fruits", and events carry the parsed parts - ideal for a server-backed lookup in a Turbo app, since streamed-in items register automatically:

root.addEventListener('cmdk-scope-change', (e) => {
  if (e.detail.scope === 'fruits') frame.src = `/search/fruits?q=${e.detail.query}`
})

The picker prefix is configurable (scope_picker: ':') or can be turned off (scope_picker: false). Server-render an already-pinned scope with Cmdk::Root(active_scope: 'fruits'). Programmatic: Cmdk.enterScope(root, 'fruits') / Cmdk.exitScope(root).

By default scoped items also match global (unscoped) searches. Mark a group or item with scope_only: true to require deliberate entry - it stays hidden (and excluded from the result count) unless its scope is active:

Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true) { ... }

Server-backed scopes - for data that lives in your database (fruits, documents), mark the scoped group server_filtered: true and put a turbo-frame inside it. The runtime then shows the streamed-in items as-is instead of fuzzy-matching them against the query - which means the query can be a server-side grammar, e.g. color:red sweet:

Cmdk::Group(heading: 'Fruits', scope: 'fruits', scope_only: true, server_filtered: true) do
  turbo_frame(id: 'fruit-results')
end
searchChanged({ detail: { scope, query } }) {       // Stimulus base controller hook
  if (scope === 'fruits') frame.src = `/search/fruits?q=${encodeURIComponent(query)}`
}

The endpoint parses the predicates, queries the database and renders Cmdk::Items into the frame; selection, keyboard navigation, footer hints and the empty state all work on the streamed items automatically.

Fully custom syntax - the filter function receives the item element as a 4th argument (an extension over the React signature), so any operator grammar is possible:

Cmdk.setFilter(root, (value, query, keywords, item) => {
  // parse your own syntax here; return 0 to hide, 0..1 to rank
  return Cmdk.defaultFilter(value, query, keywords)
})

Raycast-style palettes show a footer hinting at what Enter will do for the selected item. Declare hints on items and drop a Cmdk::Footer after the list:

Cmdk::Item(hint: 'Open in New Tab', kbd: '⌘ ↵') { 'Figma' }

Cmdk::Footer() do            # or no block for just the hint container
  span { '🚀' }
  div('cmdk-footer-hint' => '')
end

The runtime fills [cmdk-footer-hint] as the selection moves - the hint text in a <span>, each key of kbd: as its own <kbd> cap - and sets data-empty when the selected item declares no hint. For anything richer, drive your own footer from the cmdk-value-change event.

With Stimulus

The bubbling events work with plain action descriptors - no controller required:

<div data-controller="palette"
     data-action="cmdk-item-select->palette#run cmdk-scope-change->palette#search">

For more structure, the gem ships an optional base controller (Cmdk.stimulus_controller_path; serve it next to the runtime, it imports ./cmdk.js and @hotwired/stimulus). Extend it and override the hooks:

import CmdkController from 'cmdk_controller' // pin to Cmdk.stimulus_controller_path

export default class extends CmdkController {
  itemSelected({ detail: { value } }) { this.run(value) }
  scopeChanged({ detail: { scope, query } }) { /* server-backed lookup */ }
}

Hooks: itemSelected, valueChanged, searchChanged, scopeChanged. API and actions: open/close/toggle (dialog), setSearch, setValue, enterScope (param-friendly: data-action="cmdk#enterScope" data-cmdk-scope-param="fruits"), exitScope, and a state getter.

Styling

Unstyled by design: the components ship no styles, only the cmdk-* attribute contract. With Tailwind, the idiomatic way is utilities on the components themselves - the runtime toggles data-* attributes, so Tailwind’s data variants cover the states:

Cmdk::Item(class: 'flex h-10 items-center rounded-lg px-3
                   data-[selected=true]:bg-neutral-100
                   data-[disabled=true]:text-neutral-300') { 'Apple' }

Or target the attribute contract from a stylesheet (plain CSS, no build needed):

[cmdk-item][data-selected='true'] { background: #f5f5f5; }
[cmdk-group-heading] { padding: 8px 12px 6px; font-size: 12px; color: #a3a3a3; }
[cmdk-list] { height: min(330px, var(--cmdk-list-height)); transition: height 100ms ease; }

The gem also ships a default theme as plain, dependency-free CSS (assets/css/cmdk_themes.css, path via Cmdk.stylesheet_path). Opt in with class: 'cmdk' on the root - it only styles menus you ask it to, never the ones you style yourself. The look is driven by CSS variables, so you re-theme by overriding a handful of tokens instead of rewriting selectors:

/* The defaults (override any of these to re-theme): */
:root {
  --cmdk-radius: 12px;   --cmdk-item-radius: 8px;   --cmdk-pill-radius: 6px;
  --cmdk-bg:        light-dark(#ffffff, #18181b);
  --cmdk-fg:        light-dark(#171717, #ededef);
  --cmdk-muted:     light-dark(#a3a3a3, #71717a);   /* headings, footer, placeholder */
  --cmdk-border:    light-dark(#e5e5e5, #27272a);
  --cmdk-accent:    light-dark(#f5f5f5, #27272a);   /* selected row */
  --cmdk-accent-fg: light-dark(#0a0a0a, #fafafa);
  --cmdk-pill:      light-dark(#e5e5e5, #3f3f46);
  --cmdk-pill-fg:   light-dark(#404040, #d4d4d8);
}

/* Re-theme globally or on a wrapper by overriding tokens: */
:root { --cmdk-accent: #ffe08a; --cmdk-radius: 6px; }

Two ready-made looks ship as token presets: class: 'cmdk-linear' or 'cmdk-raycast' ('cmdk-vercel' is an alias for the default). All are browsable in Lookbook under “Themes”. The styling page has a live token builder that emits these overrides as CSS or Tailwind.

Dark mode - the shipped themes declare every color with light-dark() and resolve through color-scheme, giving the standard tri-state:

:root { color-scheme: light dark; }              /* "system": the OS decides */
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark']  { color-scheme: dark; }

Leave data-theme off (or system) to follow the OS preference; set <html data-theme="dark"> to force a side - no duplicated selectors, one declaration per color. The Lookbook previews expose this as a Theme dropdown in the preview toolbar.

React → Phlex parity map

React Here
<Command label shouldFilter loop vimBindings disablePointerSelection defaultValue> Cmdk::Root(label:, should_filter:, loop:, vim_bindings:, disable_pointer_selection:, default_value:)
<Command value onValueChange> (controlled) Cmdk.setValue(root, v) + cmdk-value-change event
filter={fn} Cmdk.setFilter(fn) or Cmdk.setFilter(root, fn) - same (value, search, keywords) → 0..1 signature
<Command.Input value onValueChange> Cmdk::Input(value:); Cmdk.setSearch(root, q); cmdk-search-change
<Command.List label> Cmdk::List(label:)
<Command.Item value keywords disabled forceMount onSelect> Cmdk::Item(value:, keywords:, disabled:, force_mount:); cmdk-item-select event; value inferred from text content when omitted
<Command.Group heading value forceMount> Cmdk::Group(heading:, value:, force_mount:)
<Command.Separator alwaysRender> Cmdk::Separator(always_render:)
<Command.Empty> / <Command.Loading progress label> Cmdk::Empty() / Cmdk::Loading(progress:, label:)
<Command.Dialog open onOpenChange container> Cmdk::Dialog(open:, hotkey:) - native <dialog>, no portal needed
useCommandState(selector) Cmdk.getState(root) + the events above
vim bindings, Home/End, alt/meta arrows, IME guard identical, ported from the same keydown logic

Extensions beyond the React API: Cmdk::Item(href:) visits a URL on select (via Turbo when present), and clearing the search restores the server-rendered order (React leaves the sorted order in place).

Demo, previews & tests

bundle install
bundle exec rake test      # component markup contract tests
bundle exec rake demo      # builds Tailwind CSS, serves http://localhost:9292
bundle exec rake lookbook  # Lookbook component previews on http://localhost:9293

The Lookbook previews live in lookbook/ - Lookbook is a Rails engine, so a minimal single-file Rails host (lookbook/app.rb) boots it; the gem itself stays Rails-free. Scenarios cover the default menu (with live params for placeholder/loop/vim bindings), ungrouped items, should_filter: false, force-mounted items, loading, the empty state, the event log, and the ⌘K dialog.