How to create a multistep command bar with Tailwind CSS and Alpinejs
Build a three-step automation command bar with Alpine.js, including filtering, keyboard control, and dynamic forms
LEt’s build a commandbar
Multistep command bars let people run complex actions without leaving the current page. The example below combines Tailwind CSS for structure and Alpine.js for the behavior: search, keyboard navigation, dynamic forms, and a confirmation screen. We will rebuild it piece by piece and explain exactly what each line of Alpine code does.
Heads up: the snippets assume you already have Tailwind CSS and Alpine.js available in your project. Drop the component anywhere inside your page and Alpine will take over on load.
1. Base styles and skeleton markup
Start by adding a tiny helper style so anything marked with x-cloak
stays hidden until Alpine finishes booting.
<style>
:global([x-cloak]) {
display: none !important;
}
</style>
Now paste the outer wrapper. This creates the card container, wires Alpine with x-data
, and tells Alpine to listen for global keyboard shortcuts with @keydown.window
.
<div
class="w-full max-w-3xl overflow-hidden bg-white shadow-lg rounded-xl outline outline-zinc-200"
x-data="commandBar()"
x-init="init()"
x-cloak
x-show="open"
@keydown.window="onKey($event)"
>
<!-- Steps render here -->
</div>
We will define the commandBar()
function next. Using a named function instead of an inline object keeps the template tidy.
2. Alpine state, line by line
Drop the script right before the closing </body>
tag (or inline with your component). Every property has a small job, so let’s look at each one.
<script>
function commandBar() {
return {
// Basic UI state ---------------------------------------------------
open: true,
step: 1, // 1: list, 2: configure, 3: done
query: '',
filter: 'all',
selected: 0,
toast: '',
// Data -------------------------------------------------------------
favorites: new Set([2]),
current: null,
form: {},
automations: [
{
id: 1,
name: 'Onboard new customer',
desc: 'Create a CRM record and send the resources pack.',
icon: 'zap',
tags: ['Lifecycle', 'CRM'],
params: [
{ key: 'customerName', label: 'Customer name', type: 'text', placeholder: 'Jane Doe' },
{ key: 'sendWelcomeEmail', label: 'Send welcome email', type: 'toggle', default: true },
{ key: 'assignTo', label: 'Assign to', type: 'select', options: ['Michela', 'Andre', 'Lexi'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
{
id: 2,
name: 'Invoice follow-up',
desc: 'Email overdue accounts and log the interaction.',
icon: 'mail',
tags: ['Billing', 'Email'],
params: [
{ key: 'recipient', label: 'Recipient email', type: 'text', placeholder: 'finance@company.com' },
{ key: 'template', label: 'Message template', type: 'select', options: ['Polite reminder', 'Firm notice', 'Final notice'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
{
id: 3,
name: 'Post-webinar nurture',
desc: 'Send recap email and schedule follow-up tasks.',
icon: 'calendar',
tags: ['Marketing', 'Lifecycle'],
params: [
{ key: 'webinarDate', label: 'Webinar date', type: 'datetime' },
{ key: 'includeRecording', label: 'Attach recording', type: 'toggle', default: true },
{ key: 'owner', label: 'Account owner', type: 'select', options: ['Cory', 'Nyla', 'Mike'] },
],
},
{
id: 4,
name: 'Social media blast',
desc: 'Prep assets and schedule posts across channels.',
icon: 'camera',
tags: ['Marketing', 'Social'],
params: [
{ key: 'campaignName', label: 'Campaign name', type: 'text', placeholder: 'Summer Product Drop' },
{ key: 'channels', label: 'Channels', type: 'select', options: ['All', 'Instagram', 'LinkedIn', 'TikTok'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
],
scheduled: [
{ id: 2, name: 'Invoice follow-up', at: 'Today · 4:00 PM' },
{ id: 3, name: 'Post-webinar nurture', at: 'Tomorrow · 9:00 AM' },
],
// Lifecycle --------------------------------------------------------
init() {
this.$nextTick(() => this.$refs.input?.focus());
this.$watch('query', () => (this.selected = 0));
this.$watch('filter', () => (this.selected = 0));
},
// Derived data -----------------------------------------------------
list() {
let items = [...this.automations];
if (this.filter === 'favorites') {
items = items.filter((item) => this.favorites.has(item.id));
} else if (this.filter === 'scheduled') {
const scheduledIds = new Set(this.scheduled.map((item) => item.id));
items = items.filter((item) => scheduledIds.has(item.id));
}
if (this.query.trim()) {
const q = this.query.toLowerCase();
items = items.filter((item) => (
item.name.toLowerCase().includes(q) ||
item.desc.toLowerCase().includes(q) ||
item.tags.some((tag) => tag.toLowerCase().includes(q))
));
}
if (items.length === 0) {
this.selected = 0;
} else if (this.selected >= items.length) {
this.selected = items.length - 1;
}
return items;
},
// Mutations --------------------------------------------------------
toggleFav(id) {
const favs = new Set(this.favorites);
if (favs.has(id)) {
favs.delete(id);
} else {
favs.add(id);
}
this.favorites = favs;
},
defaultParams(item) {
const values = {};
(item.params || []).forEach((param) => {
if (Object.prototype.hasOwnProperty.call(param, 'default')) {
values[param.key] = param.default;
return;
}
if (param.type === 'toggle') {
values[param.key] = false;
return;
}
if (param.type === 'select') {
values[param.key] = param.options?.[0] || '';
return;
}
if (param.type === 'datetime') {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
values[param.key] = now.toISOString().slice(0, 16);
return;
}
if (param.type === 'text') {
values[param.key] = '';
return;
}
values[param.key] = null;
});
return values;
},
openWizard(item) {
this.current = item;
this.form = { ...this.defaultParams(item) };
this.step = 2;
this.toast = '';
this.open = true;
},
back() {
this.step = 1;
this.current = null;
this.toast = '';
this.$nextTick(() => this.$refs.input?.focus());
},
quickRun(item) {
this.current = item;
this.toast = `${item.name} is running now.`;
this.step = 3;
this.open = true;
},
runNow() {
if (!this.current) return;
this.toast = `${this.current.name} launched successfully.`;
this.step = 3;
this.open = true;
},
scheduleAt() {
if (!this.current) return;
let runAtLabel = 'Soon';
if (this.form.scheduleAt) {
const parsed = new Date(this.form.scheduleAt);
if (!Number.isNaN(parsed.getTime())) {
runAtLabel = parsed.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
this.scheduled = [
...this.scheduled,
{ id: this.current.id, name: this.current.name, at: runAtLabel },
];
this.toast = `${this.current.name} scheduled for ${runAtLabel}.`;
this.step = 3;
this.open = true;
},
// Keyboard support -------------------------------------------------
onKey(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.open = true;
this.step = 1;
this.$nextTick(() => this.$refs.input?.focus());
return;
}
if (!this.open) return;
const tag = event.target.tagName.toLowerCase();
if (event.key === '/' && tag !== 'input' && tag !== 'textarea') {
event.preventDefault();
this.$nextTick(() => this.$refs.input?.focus());
return;
}
if (this.step === 1) {
const items = this.list();
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!items.length) return;
this.selected = (this.selected + 1) % items.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (!items.length) return;
this.selected = (this.selected - 1 + items.length) % items.length;
return;
}
if (event.key === 'Enter') {
const item = items[this.selected];
if (!item) return;
event.preventDefault();
if (event.shiftKey) {
this.quickRun(item);
} else {
this.openWizard(item);
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.open = false;
return;
}
}
if (this.step === 2) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
this.scheduleAt();
return;
}
if (!event.shiftKey && !(event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
this.runNow();
return;
}
if (event.shiftKey && event.key === 'Enter') {
event.preventDefault();
this.runNow();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.back();
return;
}
}
if (this.step === 3 && event.key === 'Escape') {
event.preventDefault();
this.open = false;
}
},
};
}
</script>
Cheat sheet: what each block controls
Section | Purpose |
---|---|
open , step , query , filter , selected , toast | Top-level flags controlling which step is visible and what feedback the user sees. |
favorites , current , form , automations , scheduled | Data backing the list, pre-filled form values, and the confirmation screen. |
init | Focuses the search field and resets selected whenever the query or filter changes. |
list | Applies filtering/searching, keeps the highlighted row in range, and feeds the <template x-for> . |
toggleFav , defaultParams , openWizard , back , quickRun , runNow , scheduleAt | Mutations that move the workflow forward, inject defaults, and record scheduled runs. |
onKey | Captures global keyboard shortcuts (⌘/Ctrl+K , / , arrow keys, Enter, Escape) and routes them to the right action. |
3. Populate the automations array
Each automation entry drives both the search list and the dynamic form in step 2. You can keep the sample data or replace it with your own tasks.
automations: [
{
id: 1,
name: 'Onboard new customer',
desc: 'Create a CRM record and send the resources pack.',
icon: 'zap',
tags: ['Lifecycle', 'CRM'],
params: [
{ key: 'customerName', label: 'Customer name', type: 'text', placeholder: 'Jane Doe' },
{ key: 'sendWelcomeEmail', label: 'Send welcome email', type: 'toggle', default: true },
{ key: 'assignTo', label: 'Assign to', type: 'select', options: ['Michela', 'Andre', 'Lexi'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
// ...add more automations here
],
Add or remove fields in the params
array and Alpine instantly rebuilds the configuration form. Supported field types in this setup are text
, toggle
, select
, and datetime
.
4. Rendering the three steps
The template uses <template x-if>
blocks to swap between steps.
Step 1 – search, filters, and results
- Search input uses
x-model="query"
so every keystroke updatesquery
and re-runslist()
. - Buttons update the
filter
property and use Tailwind classes to show the active state. - The results
<template x-for>
iterates overlist()
, highlighting the currently selected row withselected===idx
. - Action buttons call
toggleFav
,quickRun
, oropenWizard
.
Step 2 – dynamic configuration form
- The header shows
current?.name
. - The grid loops over
current.params
and picks an input control based onp.type
. - Each control binds to
form[p.key]
, so Alpine builds an object like{ scope: 'All', notify: 'ops@company.com' }
. - The footer buttons call
scheduleAt()
orrunNow()
and provide aBack
button to return to step 1.
Step 3 – confirmation
- Shows a success icon and the latest
toast
message. - “Run another” resets
step
to 1 and focuses the search input. - “Close” simply sets
open
tofalse
so the panel can be hidden by your layout logic.
5. Keyboard shortcuts and accessibility
Cmd/Ctrl + K
or/
opens the palette and focuses the search input.- Arrow keys move between list rows.
Enter
opens the wizard, whileShift + Enter
executes the quick run shortcut.- In the form step,
Enter
(orShift + Enter
) runs the automation andCmd/Ctrl + Enter
schedules it. Escape
backs out of the current step or closes the bar entirely.
To keep the component accessible:
- List items respond to both mouse hover and keyboard selection.
- Buttons include
aria-label
or hidden text (for example, the favorite toggle). - Consider wrapping the confirmation toast in
aria-live="polite"
if you want screen readers to announce status changes automatically.
6. Variations you can try
- Programmatic trigger: expose the component globally and call
commandBarInstance.openWizard(automation)
from anywhere in your app. - Server data: replace the
automations
array with a fetch call ininit()
and updateform
defaults once the data arrives. - Custom fields: add new
type
branches in the param renderer (for example, a textarea or radio group). - Persist schedules: push
scheduled
entries to your backend instead of keeping them in memory.
Full component snippet
Drop everything below into a new component to see it in action. The markup matches the live example shown earlier and uses the commandBar()
helper.
<style>
:global([x-cloak]) {
display: none !important;
}
</style>
<div
class="w-full max-w-3xl overflow-hidden bg-white shadow-lg rounded-xl outline outline-zinc-200"
x-data="commandBar()"
x-init="init()"
x-cloak
x-show="open"
@keydown.window="onKey($event)"
>
<!-- Header: search + filters -->
<div class="p-2">
<div class="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-arrow-search size-5 pointer-events-none absolute top-3.5 left-4 text-zinc-400"
slot="icon"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path>
<path d="M21 21l-6 -6"></path>
</svg>
<input
x-ref="input"
x-model="query"
@focus="open=true"
class="w-full h-12 bg-transparent border-0 rounded-lg pr-28 text-zinc-800 placeholder-zinc-400 pl-11 sm:text-sm ring-1 ring-zinc-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Run or schedule an automation…"
autocomplete="off"
/>
<div
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex gap-1 text-[0.70rem] text-zinc-500"
>
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200">⌘</kbd>
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200">K</kbd>
</div>
<button
type="button"
x-show="query.length>0"
@click="query=''; $nextTick(()=> $refs.input?.focus())"
class="absolute right-[3.75rem] top-1/2 -translate-y-1/2 size-7 inline-flex items-center justify-center rounded-md text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100"
aria-label="Clear search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-wave-x size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Filters -->
<div class="mt-2 px-2 flex items-center gap-2 text-[12px]">
<button
@click="filter='all'"
:class="filter==='all' ? 'bg-zinc-100 text-zinc-900 ' : 'text-zinc-600 '"
class="px-2 py-1 rounded-md"
>
All
</button>
<button
@click="filter='favorites'"
:class="filter==='favorites' ? 'bg-zinc-100 text-zinc-900 ' : 'text-zinc-600 '"
class="px-2 py-1 rounded-md"
>
Favorites
</button>
<button
@click="filter='scheduled'"
:class="filter==='scheduled' ? 'bg-zinc-100 text-zinc-900 ' : 'text-zinc-600 '"
class="px-2 py-1 rounded-md"
>
Scheduled
</button>
</div>
</div>
<!-- STEP 1: Automation list -->
<template x-if="step===1">
<div class="py-1">
<div class="px-6 py-1 text-[0.70rem] font-medium text-zinc-500">
Automations
</div>
<ul class="divide-y divide-zinc-100">
<template x-for="(a, idx) in list()" :key="a.id">
<li
:class="selected===idx ? 'bg-zinc-50 ' : ''"
class="flex items-center justify-between px-6 py-3 cursor-pointer gap-3 text-zinc-900"
@mouseenter="selected = idx"
@click="openWizard(a)"
>
<div class="flex items-center gap-3">
<div class="bg-white rounded-lg size-8 grid place-items-center">
<template x-if="a.icon === 'zap'">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5 text-zinc-500"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M7 3h10l-6 7h4l-6 11v-8h-4z"></path>
</svg>
</template>
<template x-if="a.icon === 'mail'">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5 text-zinc-500"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 7h18v10H3z"></path>
<path d="m3 7 9 6 9-6"></path>
</svg>
</template>
<template x-if="a.icon === 'camera'">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5 text-zinc-500"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 7h3l2-2h6l2 2h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1"></path>
<path d="M12 13a3 3 0 1 0 3 3 3 3 0 0 0-3-3"></path>
</svg>
</template>
<template x-if="a.icon === 'calendar'">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5 text-zinc-500"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 5h16v16H4z"></path>
<path d="M16 3v4"></path>
<path d="M8 3v4"></path>
<path d="M4 11h16"></path>
<path d="M11 15h1"></path>
<path d="M12 15v3"></path>
</svg>
</template>
</div>
<div>
<div class="text-sm font-medium" x-text="a.name"></div>
<div class="text-xs text-zinc-500" x-text="a.desc"></div>
</div>
</div>
<div class="flex items-center gap-2">
<template x-for="t in a.tags" :key="t">
<span
class="hidden md:inline text-[0.70rem] px-2 py-0.5 rounded-full bg-zinc-100 text-zinc-700"
x-text="t"
></span>
</template>
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-7 p-0.5 text-xs rounded-md"
@click.stop="toggleFav(a.id)"
:aria-pressed="favorites.has(a.id)"
:class="favorites.has(a.id)?'text-amber-500':'text-zinc-600'"
>
<span class="sr-only">Toggle favorite</span>
★
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-7 px-3 text-xs"
@click.stop="quickRun(a)"
>
Run
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-7 px-3 text-xs"
@click.stop="openWizard(a)"
>
Configure
</button>
</div>
</li>
</template>
<li
x-show="list().length===0"
class="px-6 py-6 text-sm italic text-center text-zinc-500"
>
Nothing to show
</li>
</ul>
<div class="px-6 py-3" x-show="scheduled.length">
<div class="text-[0.70rem] font-medium text-zinc-500 mb-2">
Upcoming
</div>
<ul class="text-xs space-y-1 text-zinc-600">
<template x-for="s in scheduled" :key="s.id + s.at">
<li>
<span class="font-medium" x-text="s.name"></span>
·
<span x-text="s.at"></span>
</li>
</template>
</ul>
</div>
</div>
</template>
<!-- STEP 2: Configure form -->
<template x-if="step===2">
<div class="px-4 py-3">
<div class="px-2 text-[0.70rem] text-zinc-500">Configure</div>
<div class="px-2 mt-2 text-sm font-medium" x-text="current?.name"></div>
<div class="items-center px-2 mt-2 grid grid-cols-1 md:grid-cols-2 gap-3">
<template x-for="p in current?.params || []" :key="p.key">
<div>
<label class="text-[0.70rem] text-zinc-600 block" x-text="p.label"></label>
<template x-if="p.type==='text'">
<input
type="text"
class="w-full h-9 px-2 rounded-md ring-1 ring-zinc-200 bg-transparent text-sm mt-0.5 border-zinc-200"
:placeholder="p.placeholder || ''"
x-model="form[p.key]"
/>
</template>
<template x-if="p.type==='toggle'">
<label class="flex items-center text-sm gap-2">
<input
type="checkbox"
class="border accent-zinc-900 rounded-md border-zinc-200"
x-model="form[p.key]"
/>
<span class="text-zinc-500" x-text="form[p.key] ? 'Enabled' : 'Disabled'"></span>
</label>
</template>
<template x-if="p.type==='select'">
<select
class="w-full h-9 px-2 rounded-md ring-1 ring-zinc-200 bg-transparent text-sm border-zinc-200 mt-0.5"
x-model="form[p.key]"
>
<template x-for="opt in p.options || []" :key="opt">
<option x-text="opt"></option>
</template>
</select>
</template>
<template x-if="p.type==='datetime'">
<input
type="datetime-local"
class="w-full px-2 text-sm bg-transparent h-9 rounded-md ring-1 ring-zinc-200 border-zinc-200"
x-model="form[p.key]"
/>
</template>
</div>
</template>
</div>
<div class="flex items-center justify-between px-2 mt-4">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-7 px-3 text-xs"
@click="back()"
>
Back
</button>
<div class="flex items-center gap-2">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-7 px-3 text-xs"
title="Cmd/Ctrl + Enter"
@click="scheduleAt()"
>
Schedule
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus-visible:outline-zinc-950 h-7 px-3 text-xs"
@click="runNow()"
>
Run now
</button>
</div>
</div>
</div>
</template>
<!-- STEP 3: Done -->
<template x-if="step===3">
<div class="px-6 py-10 text-center">
<div class="inline-flex items-center justify-center rounded-full size-12 bg-emerald-100 text-emerald-700">
✓
</div>
<div class="mt-3 text-sm font-medium text-zinc-900" x-text="toast || 'Completed'"></div>
<div class="mt-2 text-xs text-zinc-600">
You can close this panel or run another automation.
</div>
<div class="flex items-center justify-center mt-4 gap-2">
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus-visible:outline-zinc-950 h-7 px-3 text-xs"
@click="step=1; open=true; query=''; toast=''; $nextTick(()=> $refs.input?.focus())"
>
Run another
</button>
<button
class="relative flex items-center justify-center text-center font-medium transition-colors duration-200 ease-in-out select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:z-10 justify-center rounded-md text-zinc-600 bg-zinc-50 outline outline-zinc-100 hover:bg-zinc-200 focus-visible:outline-zinc-600 h-7 px-3 text-xs"
@click="open=false"
>
Close
</button>
</div>
</div>
</template>
<!-- Footer help -->
<div class="px-4 py-2 text-[0.70rem] text-zinc-500 flex items-center justify-between bg-zinc-50">
<div>
Press
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200 text-zinc-600">/</kbd>
to search •
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200 text-zinc-600">Shift</kbd>
+
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200 text-zinc-600">↵</kbd>
quick run
</div>
<div class="items-center hidden sm:flex gap-2">
<span class="flex items-center gap-1">
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200 text-zinc-600">↑</kbd>
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200 text-zinc-600">↓</kbd>
to navigate
</span>
<span class="flex items-center gap-1">
<kbd class="px-1 border rounded bg-zinc-100 border-zinc-200 text-zinc-600">↵</kbd>
to configure
</span>
</div>
</div>
</div>
<script>
function commandBar() {
return {
// Basic UI state ---------------------------------------------------
open: true,
step: 1, // 1: list, 2: configure, 3: done
query: '',
filter: 'all',
selected: 0,
toast: '',
// Data -------------------------------------------------------------
favorites: new Set([2]),
current: null,
form: {},
automations: [
{
id: 1,
name: 'Onboard new customer',
desc: 'Create a CRM record and send the resources pack.',
icon: 'zap',
tags: ['Lifecycle', 'CRM'],
params: [
{ key: 'customerName', label: 'Customer name', type: 'text', placeholder: 'Jane Doe' },
{ key: 'sendWelcomeEmail', label: 'Send welcome email', type: 'toggle', default: true },
{ key: 'assignTo', label: 'Assign to', type: 'select', options: ['Michela', 'Andre', 'Lexi'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
{
id: 2,
name: 'Invoice follow-up',
desc: 'Email overdue accounts and log the interaction.',
icon: 'mail',
tags: ['Billing', 'Email'],
params: [
{ key: 'recipient', label: 'Recipient email', type: 'text', placeholder: 'finance@company.com' },
{ key: 'template', label: 'Message template', type: 'select', options: ['Polite reminder', 'Firm notice', 'Final notice'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
{
id: 3,
name: 'Post-webinar nurture',
desc: 'Send recap email and schedule follow-up tasks.',
icon: 'calendar',
tags: ['Marketing', 'Lifecycle'],
params: [
{ key: 'webinarDate', label: 'Webinar date', type: 'datetime' },
{ key: 'includeRecording', label: 'Attach recording', type: 'toggle', default: true },
{ key: 'owner', label: 'Account owner', type: 'select', options: ['Cory', 'Nyla', 'Mike'] },
],
},
{
id: 4,
name: 'Social media blast',
desc: 'Prep assets and schedule posts across channels.',
icon: 'camera',
tags: ['Marketing', 'Social'],
params: [
{ key: 'campaignName', label: 'Campaign name', type: 'text', placeholder: 'Summer Product Drop' },
{ key: 'channels', label: 'Channels', type: 'select', options: ['All', 'Instagram', 'LinkedIn', 'TikTok'] },
{ key: 'scheduleAt', label: 'Schedule run', type: 'datetime' },
],
},
],
scheduled: [
{ id: 2, name: 'Invoice follow-up', at: 'Today · 4:00 PM' },
{ id: 3, name: 'Post-webinar nurture', at: 'Tomorrow · 9:00 AM' },
],
// Lifecycle --------------------------------------------------------
init() {
this.$nextTick(() => this.$refs.input?.focus());
this.$watch('query', () => (this.selected = 0));
this.$watch('filter', () => (this.selected = 0));
},
// Derived data -----------------------------------------------------
list() {
let items = [...this.automations];
if (this.filter === 'favorites') {
items = items.filter((item) => this.favorites.has(item.id));
} else if (this.filter === 'scheduled') {
const scheduledIds = new Set(this.scheduled.map((item) => item.id));
items = items.filter((item) => scheduledIds.has(item.id));
}
if (this.query.trim()) {
const q = this.query.toLowerCase();
items = items.filter((item) => (
item.name.toLowerCase().includes(q) ||
item.desc.toLowerCase().includes(q) ||
item.tags.some((tag) => tag.toLowerCase().includes(q))
));
}
if (items.length === 0) {
this.selected = 0;
} else if (this.selected >= items.length) {
this.selected = items.length - 1;
}
return items;
},
// Mutations --------------------------------------------------------
toggleFav(id) {
const favs = new Set(this.favorites);
if (favs.has(id)) {
favs.delete(id);
} else {
favs.add(id);
}
this.favorites = favs;
},
defaultParams(item) {
const values = {};
(item.params || []).forEach((param) => {
if (Object.prototype.hasOwnProperty.call(param, 'default')) {
values[param.key] = param.default;
return;
}
if (param.type === 'toggle') {
values[param.key] = false;
return;
}
if (param.type === 'select') {
values[param.key] = param.options?.[0] || '';
return;
}
if (param.type === 'datetime') {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
values[param.key] = now.toISOString().slice(0, 16);
return;
}
if (param.type === 'text') {
values[param.key] = '';
return;
}
values[param.key] = null;
});
return values;
},
openWizard(item) {
this.current = item;
this.form = { ...this.defaultParams(item) };
this.step = 2;
this.toast = '';
this.open = true;
},
back() {
this.step = 1;
this.current = null;
this.toast = '';
this.$nextTick(() => this.$refs.input?.focus());
},
quickRun(item) {
this.current = item;
this.toast = `${item.name} is running now.`;
this.step = 3;
this.open = true;
},
runNow() {
if (!this.current) return;
this.toast = `${this.current.name} launched successfully.`;
this.step = 3;
this.open = true;
},
scheduleAt() {
if (!this.current) return;
let runAtLabel = 'Soon';
if (this.form.scheduleAt) {
const parsed = new Date(this.form.scheduleAt);
if (!Number.isNaN(parsed.getTime())) {
runAtLabel = parsed.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}
this.scheduled = [
...this.scheduled,
{ id: this.current.id, name: this.current.name, at: runAtLabel },
];
this.toast = `${this.current.name} scheduled for ${runAtLabel}.`;
this.step = 3;
this.open = true;
},
// Keyboard support -------------------------------------------------
onKey(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.open = true;
this.step = 1;
this.$nextTick(() => this.$refs.input?.focus());
return;
}
if (!this.open) return;
const tag = event.target.tagName.toLowerCase();
if (event.key === '/' && tag !== 'input' && tag !== 'textarea') {
event.preventDefault();
this.$nextTick(() => this.$refs.input?.focus());
return;
}
if (this.step === 1) {
const items = this.list();
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!items.length) return;
this.selected = (this.selected + 1) % items.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (!items.length) return;
this.selected = (this.selected - 1 + items.length) % items.length;
return;
}
if (event.key === 'Enter') {
const item = items[this.selected];
if (!item) return;
event.preventDefault();
if (event.shiftKey) {
this.quickRun(item);
} else {
this.openWizard(item);
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.open = false;
return;
}
}
if (this.step === 2) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
this.scheduleAt();
return;
}
if (!event.shiftKey && !(event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
this.runNow();
return;
}
if (event.shiftKey && event.key === 'Enter') {
event.preventDefault();
this.runNow();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.back();
return;
}
}
if (this.step === 3 && event.key === 'Escape') {
event.preventDefault();
this.open = false;
}
},
};
}
</script>
/Michael Andreuzza