Components

Command Palette with Argument Chips

Commands that take arguments, filled inline, Linear style. Pick Assign to and the command collapses into a chip, the list slides to people, and the same input now filters names. A finished command stages behind an “and” instead of running - keep stacking until the explicit ✓ Apply (or ⌘⏎) runs the whole compound, or discards it. The choreography rule: focus never leaves the one input. Chips are paint, not fields - Backspace on an empty query pops the last chip, even back across an “and” into a staged command, and Escape backs out a layer, exactly like a path. Matching is a real fuzzy subsequence - type mvp and watch it find Move to project - scored with word-start and adjacency bonuses minus a gap penalty, the matched letters underlined in place (underline, not bold, so rows never reflow as you type). The list's height is a measured px easing between states, and chips grow in and pop instantly because Backspace must feel immediate.

  • Assign to
  • Set due date
  • Move to project
  • Add label
  • Copy issue link
  • Subscribe to updates
  • Archive issue
7 commands
7 results

Mode: commands · 7 matches

Install

npx moumenlab add command-palette

For AI

Open .md

Usage

"use client";

import type { ReactNode } from "react";
import CommandPalette, { type Command } from "./command-palette";

// Commands are data: label + icon + shortcut + typed argument slots, each with
// its own options and a `message` for the applied summary. Pass your own and
// handle onApply(clauses) to run the compound.
export default function CommandPaletteExample() {
  return <CommandPalette commands={COMMANDS} onApply={(clauses) => console.log("apply", clauses)} />;
}

const PEOPLE = [
  { value: "Sarah Chen", hint: "Design" },
  { value: "Omar Farouk", hint: "Engineering" },
  { value: "June Park", hint: "Product" },
  { value: "Maya Lindberg", hint: "Engineering" },
  { value: "Leo Okafor", hint: "Data" },
];
const PRIORITIES = [
  { value: "Urgent", dot: "#dc2626" },
  { value: "High", dot: "#f59e0b" },
  { value: "Medium", dot: "#3b82f6" },
  { value: "Low", dot: "#9ca3af" },
];
const PROJECTS = [{ value: "Website Redesign" }, { value: "Mobile App" }, { value: "Design System" }, { value: "Billing Service" }];
const LABELS = [
  { value: "Bug", dot: "#dc2626" },
  { value: "Feature", dot: "#3b82f6" },
  { value: "Improvement", dot: "#10b981" },
  { value: "Docs", dot: "#8b5cf6" },
  { value: "Chore", dot: "#9ca3af" },
];

function dueOptions() {
  const fmt = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric" });
  const day = (offset: number) => fmt.format(new Date(Date.now() + offset * 86400000));
  return [
    { value: "Today", hint: day(0) },
    { value: "Tomorrow", hint: day(1) },
    { value: "Next week", hint: day(7) },
    { value: "Next month", hint: day(30) },
    { value: "No due date" },
  ];
}

export const COMMANDS: Command[] = [
  {
    id: "assign",
    label: "Assign to",
    icon: <CommandIcon name="user" />,
    shortcut: "A",
    slots: [
      { name: "assignee", prompt: "Assign to whom", kind: "person", options: PEOPLE },
      { name: "priority", prompt: "With what priority", kind: "dot", options: PRIORITIES },
    ],
    message: (v) => `Assigned to ${v[0].value} · ${v[1].value} priority`,
  },
  {
    id: "due",
    label: "Set due date",
    icon: <CommandIcon name="calendar" />,
    shortcut: "D",
    slots: [{ name: "due", prompt: "When is it due", kind: "plain", options: dueOptions() }],
    message: (v) => (v[0].value === "No due date" ? "Due date cleared" : `Due ${v[0].value.toLowerCase()}${v[0].hint ? ` · ${v[0].hint}` : ""}`),
  },
  {
    id: "move",
    label: "Move to project",
    icon: <CommandIcon name="folder" />,
    shortcut: "M",
    slots: [{ name: "project", prompt: "Which project", kind: "plain", options: PROJECTS }],
    message: (v) => `Moved to ${v[0].value}`,
  },
  {
    id: "label",
    label: "Add label",
    icon: <CommandIcon name="tag" />,
    shortcut: "L",
    slots: [{ name: "label", prompt: "Which label", kind: "dot", options: LABELS }],
    message: (v) => `Labeled ${v[0].value}`,
  },
  { id: "copy", label: "Copy issue link", icon: <CommandIcon name="link" />, shortcut: "⌘C", slots: [], message: () => "Link copied to clipboard" },
  { id: "subscribe", label: "Subscribe to updates", icon: <CommandIcon name="bell" />, shortcut: "S", slots: [], message: () => "Subscribed to this issue" },
  { id: "archive", label: "Archive issue", icon: <CommandIcon name="archive" />, danger: true, slots: [], message: () => "Issue archived" },
];

function CommandIcon({ name }: { name: string }): ReactNode {
  const p = { width: 15, height: 15, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true };
  if (name === "user") return <svg {...p}><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>;
  if (name === "calendar") return <svg {...p}><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>;
  if (name === "folder") return <svg {...p}><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></svg>;
  if (name === "tag") return <svg {...p}><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42Z" /><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" /></svg>;
  if (name === "link") return <svg {...p}><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>;
  if (name === "bell") return <svg {...p}><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /></svg>;
  return <svg {...p}><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" /><path d="M10 12h4" /></svg>;
}

Story

  1. Taken from GitLab's search bar

    This one is GitLab's idea, not mine. Their search does not ask you to write a query; it hands you a field list and turns each pick into a token you can read and remove: a key, an operator, a value. I wanted that model in a design system, as one component.

  2. Complex and clear at the same time

    The requirement pulled in two directions. Search had to handle compound queries, several fields at once, and it had to stay legible to people who would never learn a syntax. A text query gets you the first and loses the second. Chips get you both: you pick instead of typing, and the result reads back as itself.

  3. One focusable control, chips are paint

    What keeps a long query clear is that it never fragments. Give every chip its own focusable element and Tab order, Backspace and screen-reader context all break mid-query. So the palette has exactly one focusable control, the input, for its whole life. Chips are render output: Backspace on an empty query pops the last one, even back across an “and”, and clicking a chip rewinds to that slot.

  4. It hands over data, not a string

    A finished command stages instead of running, so one session builds the whole compound and nothing fires until ✓ Apply (⌘⏎). What Apply passes on is the point: each clause leaves as its command id plus a value per named slot, so the query arrives at the backend as structure, with nothing left to parse.

References