Summit Themes
Blog

How to create a multistep command bar with Tailwind CSS and Alpine.js

Command bars — the floating palette you summon with Ctrl+K or Cmd+K — are one of those UI patterns that instantly make an interface feel professional. Most tutorials show you a single-step search box. This one goes further: we'll build a multistep command bar that accepts a keyboard shortcut, transitions smoothly between steps, and closes on Escape. The only dependencies are Alpine.js and Tailwind CSS v4, both of which you probably already have.

The finished component has three steps: choose a category, choose an action within that category, then confirm. You can adapt the pattern to any wizard-style flow — onboarding, quick-add forms, or a real command palette with real commands.

What you need

  • Tailwind CSS v4 (CSS-first setup via @import "tailwindcss")
  • Alpine.js v3 loaded via CDN or npm
  • No build step required for the component itself — it's pure HTML + CSS + Alpine directives

If you're on Tailwind v3 or earlier, the utility class names are the same; only the configuration approach differs. The Alpine.js code works identically across v3 environments.

Step 1: The HTML shell and overlay

The command bar sits in a fixed overlay that covers the whole screen. Clicking the backdrop closes it. The inner panel is centered with flexbox.

<!-- Wrap everything in one Alpine component -->
<div
  x-data="commandBar()"
  @keydown.window.ctrl.k.prevent="open = true"
  @keydown.window.meta.k.prevent="open = true"
  @keydown.window.escape="open = false"
>

  <!-- Trigger button (optional — keyboard shortcut works too) -->
  <button @click="open = true" class="...">
    Open command bar <kbd>Ctrl K</kbd>
  </button>

  <!-- Backdrop -->
  <div
    x-show="open"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0"
    class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
    @click="open = false"
  ></div>

  <!-- Panel -->
  <div
    x-show="open"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95"
    class="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
    @click.self="open = false"
  >
    <div class="w-full max-w-lg bg-white rounded-xl shadow-2xl overflow-hidden">

      <!-- Steps rendered here -->

    </div>
  </div>

</div>

A few things to note. The @keydown.window.ctrl.k.prevent modifier registers the listener on window (not just the element), and .prevent calls event.preventDefault() so the browser doesn't intercept the shortcut. On macOS, Cmd+K uses the .meta modifier — adding both listeners covers all platforms. The backdrop and panel each have independent transitions so you can tune their timing separately.

Step 2: The Alpine component data

Define the component as a named function so the x-data attribute stays clean. Place this in a <script> tag or a separate .js file.

function commandBar() {
  return {
    open: false,
    step: 1,        // 1 = pick category, 2 = pick action, 3 = confirm
    selected: {
      category: null,
      action: null,
    },

    categories: [
      { id: 'page',     label: 'Go to page' },
      { id: 'theme',    label: 'Switch theme' },
      { id: 'export',   label: 'Export data' },
    ],

    actions: {
      page:   [{ id: 'home', label: 'Home' }, { id: 'settings', label: 'Settings' }],
      theme:  [{ id: 'light', label: 'Light' }, { id: 'dark', label: 'Dark' }],
      export: [{ id: 'csv', label: 'CSV' }, { id: 'json', label: 'JSON' }],
    },

    get currentActions() {
      return this.selected.category
        ? this.actions[this.selected.category.id] ?? []
        : [];
    },

    pickCategory(cat) {
      this.selected.category = cat;
      this.step = 2;
    },

    pickAction(action) {
      this.selected.action = action;
      this.step = 3;
    },

    confirm() {
      // Run your real command here
      console.log('Run:', this.selected.category.id, this.selected.action.id);
      this.reset();
    },

    reset() {
      this.open  = false;
      this.step  = 1;
      this.selected = { category: null, action: null };
    },

    back() {
      if (this.step > 1) this.step--;
    },
  };
}

The currentActions getter is a computed property — Alpine evaluates it reactively whenever selected.category changes, so you never manually sync data between steps. The reset() method closes the bar and rewinds to step 1 in one call, which keeps both the confirm action and the close button simple.

Step 3: Rendering the steps

Inside the panel <div>, render each step conditionally with x-show. Alpine's x-show keeps all three step elements in the DOM (unlike x-if, which unmounts them), which means transitions between steps work without re-initializing the component.

<!-- Header with breadcrumb and back button -->
<div class="flex items-center gap-2 px-4 py-3 border-b border-gray-100 text-sm text-gray-500">
  <button
    x-show="step > 1"
    @click="back()"
    class="flex items-center gap-1 hover:text-gray-800 transition-colors"
  >
    &larr; Back
  </button>
  <span x-show="step === 1">Choose a category</span>
  <span x-show="step === 2">
    <span x-text="selected.category?.label"></span> &rsaquo; Choose an action
  </span>
  <span x-show="step === 3">Confirm</span>
</div>

<!-- Step 1: Categories -->
<div x-show="step === 1" class="py-2">
  <template x-for="cat in categories" :key="cat.id">
    <button
      @click="pickCategory(cat)"
      class="w-full text-left px-4 py-2.5 hover:bg-gray-50 text-sm font-medium text-gray-800 transition-colors"
      x-text="cat.label"
    ></button>
  </template>
</div>

<!-- Step 2: Actions -->
<div x-show="step === 2" class="py-2">
  <template x-for="action in currentActions" :key="action.id">
    <button
      @click="pickAction(action)"
      class="w-full text-left px-4 py-2.5 hover:bg-gray-50 text-sm text-gray-700 transition-colors"
      x-text="action.label"
    ></button>
  </template>
</div>

<!-- Step 3: Confirm -->
<div x-show="step === 3" class="px-4 py-5 space-y-4">
  <p class="text-sm text-gray-600">
    Run <strong x-text="selected.category?.label"></strong>
    &rarr; <strong x-text="selected.action?.label"></strong>?
  </p>
  <div class="flex gap-2 justify-end">
    <button
      @click="reset()"
      class="px-3 py-1.5 rounded-lg text-sm text-gray-600 hover:bg-gray-100 transition-colors"
    >Cancel</button>
    <button
      @click="confirm()"
      class="px-3 py-1.5 rounded-lg text-sm bg-gray-900 text-white hover:bg-gray-700 transition-colors"
    >Confirm</button>
  </div>
</div>

The x-for loop renders category and action buttons from the data arrays. Using :key ensures Alpine can track elements if the list changes. The optional chaining operator (?.) in x-text guards against null during the step 1→2 transition when selected.category hasn't been set yet.

A command bar without a search field is just a menu. Add a text input at step 1 that filters the category list. Hook x-ref to the input so Alpine can focus it automatically when the bar opens.

<!-- Inside the panel, before Step 1 -->
<div x-show="step === 1" class="px-3 py-2 border-b border-gray-100">
  <input
    x-ref="searchInput"
    x-model="query"
    @keydown.enter.prevent="if (filteredCategories.length === 1) pickCategory(filteredCategories[0])"
    type="text"
    placeholder="Search commands..."
    class="w-full bg-transparent text-sm text-gray-800 placeholder-gray-400 outline-none"
  />
</div>

Then extend the component data with the filter logic and a watcher that focuses the input when the bar opens:

// Add to the commandBar() return object:

query: '',

get filteredCategories() {
  if (!this.query.trim()) return this.categories;
  const q = this.query.toLowerCase();
  return this.categories.filter(c => c.label.toLowerCase().includes(q));
},

// Watch `open` and focus the input when it becomes true
init() {
  this.$watch('open', (val) => {
    if (val) {
      this.$nextTick(() => this.$refs.searchInput?.focus());
    } else {
      this.query = '';
    }
  });
},

Replace categories with filteredCategories in the step 1 x-for loop. The $nextTick call defers focus until after Alpine has made the input visible — trying to focus a display:none element silently fails.

Step 5: Keyboard navigation between items

Mouse-only command bars feel incomplete. Add arrow key navigation by tracking a cursor index and responding to ArrowDown, ArrowUp, and Enter on the search input:

// In component data:
cursor: 0,

// Update the input @keydown binding:
@keydown.arrow-down.prevent="cursor = Math.min(cursor + 1, filteredCategories.length - 1)"
@keydown.arrow-up.prevent="cursor = Math.max(cursor - 1, 0)"
@keydown.enter.prevent="pickCategory(filteredCategories[cursor])"

Then highlight the active row by binding a class conditionally in the loop:

<button
  @click="pickCategory(cat)"
  :class="filteredCategories.indexOf(cat) === cursor ? 'bg-gray-100' : 'hover:bg-gray-50'"
  class="w-full text-left px-4 py-2.5 text-sm font-medium text-gray-800 transition-colors"
  x-text="cat.label"
></button>

Step 6: Tailwind v4 custom tokens (if you want a branded palette)

If you want the command bar to use your project's brand colors rather than raw gray utilities, define them in your CSS entry file using the @theme directive. This is the v4 way — no tailwind.config.js needed.

/* global.css */
@import "tailwindcss";

@theme {
  --color-surface: #ffffff;
  --color-surface-hover: #f8f8f8;
  --color-border: #e5e5e5;
  --color-text-primary: #111111;
  --color-text-muted: #6b7280;
  --color-accent: #2563eb;
}

Tailwind v4 automatically generates utilities like bg-surface, text-text-primary, and border-border from those variables. Replace the raw Tailwind color utilities in the HTML above with your custom tokens for a consistent look across your whole site.

Putting it all together

Here's the complete sequence of what happens at runtime:

  1. User presses Ctrl+K / Cmd+Kopen becomes true, the backdrop and panel fade+scale in, the search input is focused.
  2. User types to filter categories, or uses arrow keys to navigate, then presses Enter or clicks — step becomes 2.
  3. User picks an action — step becomes 3, confirmation screen shown.
  4. User confirms — your callback runs, reset() closes and rewinds everything.
  5. At any point, Escape or clicking the backdrop calls open = false directly.

The entire component is around 80 lines of HTML and 60 lines of JavaScript, with no dependencies beyond Alpine and Tailwind. Because Alpine's reactivity is data-driven, adding a fourth step means adding a step === 4 block and a method — the rest of the wiring stays the same. That's the part that makes this pattern worth learning once and reusing everywhere.