# Inline Slash Menu — React Command Palette

> Type "/" on an editor line to open a block menu that filters as you type, moves with the arrow keys and turns the line into the chosen block.

- Category: [Command & Search](https://pixelpapa.com/components/command.md)
- Tags: command, slash, editor, menu, blocks
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/command/command-inline-slash-menu

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/command-inline-slash-menu.json"
```

The file is written to `components/pixelpapa/command-inline-slash-menu.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Code, Heading1, Heading2, List, ListChecks, Minus, Quote, Type, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type BlockType = "text" | "h1" | "h2" | "bullet" | "todo" | "quote" | "code" | "divider";
export type Block = { id: number; type: BlockType; text: string };

const BLOCKS: { type: BlockType; label: string; hint: string; icon: LucideIcon }[] = [
  { type: "text", label: "Text", hint: "Plain paragraph", icon: Type },
  { type: "h1", label: "Heading 1", hint: "Large section title", icon: Heading1 },
  { type: "h2", label: "Heading 2", hint: "Medium section title", icon: Heading2 },
  { type: "bullet", label: "Bulleted list", hint: "Simple list item", icon: List },
  { type: "todo", label: "To-do", hint: "Checkbox you can tick", icon: ListChecks },
  { type: "quote", label: "Quote", hint: "Pull out a line", icon: Quote },
  { type: "code", label: "Code", hint: "Monospace snippet", icon: Code },
  { type: "divider", label: "Divider", hint: "Thin separating rule", icon: Minus },
];

const STYLE: Record<BlockType, string> = {
  text: "text-sm text-foreground",
  h1: "text-2xl font-semibold tracking-tight text-foreground",
  h2: "text-lg font-semibold tracking-tight text-foreground",
  bullet: "text-sm text-foreground",
  todo: "text-sm text-foreground",
  quote: "border-l-2 border-primary pl-3 font-serif text-base italic text-foreground",
  code: "rounded-lg bg-muted px-3 py-2 font-mono text-xs text-foreground",
  divider: "",
};

function BlockView({ block }: { block: Block }) {
  const [done, setDone] = useState(false);
  if (block.type === "divider") return <hr className="my-2 border-border" />;
  return (
    <div className={cn("flex items-start gap-2", STYLE[block.type])}>
      {block.type === "bullet" && <span className="mt-2 size-1.5 shrink-0 rounded-full bg-foreground/60" />}
      {block.type === "todo" && (
        <input type="checkbox" checked={done} onChange={(e) => setDone(e.target.checked)} aria-label={block.text} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
      )}
      <span className={cn(done && "text-muted-foreground line-through")}>{block.text}</span>
    </div>
  );
}

type SlashEditorProps = { initialBlocks?: Block[]; initialDraft?: string; className?: string };

export function SlashEditor({ initialBlocks = [], initialDraft = "", className }: SlashEditorProps) {
  const uid = useId();
  const reduce = useReducedMotion();
  const [blocks, setBlocks] = useState(initialBlocks);
  const [type, setType] = useState<BlockType>("text");
  const [draft, setDraft] = useState(initialDraft);
  const [active, setActive] = useState(0);
  const [dismissed, setDismissed] = useState(false);

  const slash = draft.startsWith("/") && !dismissed;
  const q = draft.slice(1).toLowerCase();
  const options = slash ? BLOCKS.filter((b) => b.label.toLowerCase().includes(q)) : [];
  const open = options.length > 0;

  function choose(t: BlockType) {
    setDraft("");
    setActive(0);
    if (t === "divider") setBlocks((b) => [...b, { id: b.length + 1, type: "divider", text: "" }]);
    else setType(t);
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (open) {
      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
        e.preventDefault();
        setActive((a) => (a + (e.key === "ArrowDown" ? 1 : -1) + options.length) % options.length);
      } else if (e.key === "Enter" || e.key === "Tab") {
        e.preventDefault();
        choose(options[Math.min(active, options.length - 1)].type);
      } else if (e.key === "Escape") setDismissed(true);
      return;
    }
    if (e.key === "Enter" && draft.trim()) {
      setBlocks((b) => [...b, { id: b.length + 1, type, text: draft.trim() }]);
      setDraft("");
      if (type !== "bullet" && type !== "todo") setType("text");
    } else if (e.key === "Backspace" && !draft && type !== "text") setType("text");
  }

  const current = BLOCKS.find((b) => b.type === type);

  return (
    <div className={cn("relative w-full", className)}>
      <div className="space-y-2">
        {blocks.map((b) => (
          <motion.div key={b.id} layout={reduce ? false : "position"} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }}>
            <BlockView block={b} />
          </motion.div>
        ))}
      </div>

      <div className="relative mt-2">
        <div className={cn("flex items-center gap-2", STYLE[type])}>
          {type === "bullet" && <span className="size-1.5 shrink-0 rounded-full bg-foreground/60" />}
          {type === "todo" && <span className="size-4 shrink-0 rounded-[4px] border border-input" />}
          <input
            role="combobox"
            aria-expanded={open}
            aria-controls={`${uid}-menu`}
            aria-activedescendant={open ? `${uid}-${options[Math.min(active, options.length - 1)].type}` : undefined}
            aria-label="Editor line. Type slash for blocks."
            value={draft}
            onChange={(e) => (setDraft(e.target.value), setActive(0), setDismissed(false))}
            onKeyDown={onKeyDown}
            placeholder={type === "text" ? "Write, or type / for blocks" : current?.label}
            className="w-full min-w-0 bg-transparent outline-none placeholder:font-sans placeholder:text-sm placeholder:font-normal placeholder:tracking-normal placeholder:text-muted-foreground/70 placeholder:not-italic"
          />
        </div>

        <AnimatePresence>
          {open && (
            <motion.div
              id={`${uid}-menu`}
              role="listbox"
              aria-label="Blocks"
              initial={{ opacity: 0, y: -6, scale: 0.97 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: -4, scale: 0.98 }}
              transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
              className="absolute top-full left-0 z-20 mt-2 w-64 origin-top-left rounded-xl border border-border bg-popover p-1.5 shadow-xl"
            >
              <p className="px-2 pt-1 pb-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">Blocks</p>
              <div className="max-h-[188px] overflow-y-auto">
                {options.map((o, i) => {
                  const on = i === Math.min(active, options.length - 1);
                  return (
                    <div
                      key={o.type}
                      id={`${uid}-${o.type}`}
                      role="option"
                      aria-selected={on}
                      onPointerMove={() => !on && setActive(i)}
                      onPointerDown={(e) => (e.preventDefault(), choose(o.type))}
                      className="relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5"
                    >
                      {on && (
                        <motion.span
                          layoutId={`command-inline-slash-menu-active-${uid}`}
                          className="absolute inset-0 rounded-lg bg-accent"
                          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 40 }}
                        />
                      )}
                      <span className="relative flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-foreground">
                        <o.icon className="size-4" />
                      </span>
                      <span className="relative min-w-0">
                        <span className="block text-sm font-medium text-popover-foreground">{o.label}</span>
                        <span className="block truncate text-xs text-muted-foreground">{o.hint}</span>
                      </span>
                    </div>
                  );
                })}
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

const starter: Block[] = [
  { id: 1, type: "h1", text: "Release notes — 4.12" },
  { id: 2, type: "text", text: "Retries now share one budget per request, so a slow dependency can no longer fan out into a storm." },
];

export default function CommandInlineSlashMenuDemo() {
  return (
    <div className="h-[430px] w-full max-w-lg rounded-2xl border border-border bg-card p-6 shadow-sm">
      <p className="mb-4 text-xs text-muted-foreground">Docs / Changelog / Draft</p>
      <SlashEditor initialBlocks={starter} initialDraft="/" />
    </div>
  );
}
```

## AI prompt

```text
Add the "Inline Slash Menu" component from PixelPapa UI to this project.

What it is: Type "/" on an editor line to open a block menu that filters as you type, moves with the arrow keys and turns the line into the chosen block.
Category: Command & Search. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/command-inline-slash-menu.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/command-inline-slash-menu.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/command-inline-slash-menu" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More command & search

- [Command Palette](https://pixelpapa.com/components/command/command-palette.md): A grouped command palette with fuzzy matching that highlights matched letters, a highlight that glides between rows on arrow keys, recents and footer hints.
- [Shortcut Cheatsheet](https://pixelpapa.com/components/command/command-shortcut-cheatsheet.md): A keyboard shortcut overlay with physical-looking key caps that press down in sequence, on a demo loop, on hover, or when you type the combo.
- [Spotlight Search](https://pixelpapa.com/components/command/command-spotlight-search.md) (Pro): A large centred search field with a results column and a preview pane that crossfades to the highlighted result as you arrow through.
