Skill and workflow command palette triggered by typing / in an AI composer: filter /plan, /review, /compact, /test and /pr with keyboard navigation and argument hints.
Free to install: no account, access key or components.json setup needed.
$pnpm dlx shadcn@latest add https://nextui.dev/r/slash-commands.json
1"use client";23import { SlashCommands } from "@/components/nextui/slash-commands";45export function SkillPalette() {6return (7<SlashCommands8onRun={(cmd) => console.log("Triggered skill:", cmd.command)}9/>10);11}
| Prop | Type | Default | Description |
|---|---|---|---|
| commands | SlashCommandItem[] | – | Available /commands with argsHint, description and category |
| placeholder | string | "Type / for commands" | Placeholder text in the filter input |
| onRun | (command: SlashCommandItem, args: string) => void | – | Called when a slash command is selected |
| autoPlay | boolean | false | Loop a scripted slash-command filter demo |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Check, Compass, FlaskConical, GitPullRequest, Minimize2, ScanSearch, Sparkles, Terminal, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type SlashCommandParam = { name: string; optional?: boolean; hint: string };
export type SlashCommandItem = {
command: string;
argsHint?: string;
description: string;
category?: "workflow" | "context" | "skill";
params?: SlashCommandParam[];
};
export type SlashCommandsProps = {
commands?: SlashCommandItem[];
placeholder?: string;
/** Called with the command and the argument text typed after it. */
onRun?: (command: SlashCommandItem, args: string) => void;
/** Loop a scripted pick → fill args → run demo. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_COMMANDS: SlashCommandItem[] = [
{
command: "/plan",
argsHint: "<goal>",
description: "Draft a step-by-step plan before editing files",
category: "workflow",
params: [{ name: "goal", hint: "What should be true when the plan is done" }],
},
{
command: "/test",
argsHint: "[pattern]",
description: "Run affected tests and fix failing assertions",
category: "workflow",
params: [{ name: "pattern", optional: true, hint: "Glob or test name · defaults to changed files" }],
},
{
command: "/review",
argsHint: "[branch]",
description: "Audit the diff for bugs, security and regressions",
category: "skill",
params: [
{ name: "branch", optional: true, hint: "Compare against · defaults to the base branch" },
{ name: "--strict", optional: true, hint: "Also flag style and naming issues" },
],
},
{
command: "/pr",
argsHint: "[title]",
description: "Commit, write a summary and open a pull request",
category: "skill",
params: [{ name: "title", optional: true, hint: "Leave empty to generate one from the diff" }],
},
{
command: "/compact",
argsHint: "[focus]",
description: "Summarize earlier turns to free the context window",
category: "context",
params: [{ name: "focus", optional: true, hint: "What the summary should keep in detail" }],
},
];
const GROUPS: { id: NonNullable<SlashCommandItem["category"]>; label: string; tone: string }[] = [
{ id: "workflow", label: "Workflow", tone: "bg-sky-500" },
{ id: "skill", label: "Skill", tone: "bg-violet-500" },
{ id: "context", label: "Context", tone: "bg-amber-500" },
];
const ICONS: Record<string, typeof Compass> = {
"/plan": Compass,
"/test": FlaskConical,
"/review": ScanSearch,
"/pr": GitPullRequest,
"/compact": Minimize2,
};
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9400;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
type Phase = "pick" | "args" | "running" | "done";
type View = { input: string; active: number; chosen: string | null; phase: Phase; progress: number };
function typed(str: string, t: number, start: number, per: number) {
if (t < start) return "";
return str.slice(0, Math.min(str.length, Math.floor((t - start) / per) + 1));
}
function demoView(t: number): View {
if (t < 3000) {
const q = typed("re", t, 2300, 220);
return { input: `/${q}`, active: q ? 0 : t >= 1700 ? 1 : t >= 1200 ? 2 : t >= 700 ? 1 : 0, chosen: null, phase: "pick", progress: 0 };
}
const args = typed("main", t, 3700, 140);
if (t < 4900) return { input: `/review ${args}`, active: 0, chosen: "/review", phase: "args", progress: 0 };
if (t < 7000) return { input: "/review main", active: 0, chosen: "/review", phase: "running", progress: Math.min(1, (t - 4900) / 1900) };
return { input: "/review main", active: 0, chosen: "/review", phase: "done", progress: 1 };
}
function filter(commands: SlashCommandItem[], input: string) {
const q = input.replace(/^\//, "").split(/\s/)[0].toLowerCase();
if (!q) return commands;
const byName = commands.filter((c) => c.command.slice(1).toLowerCase().includes(q));
if (byName.length) return byName.sort((a, b) => Number(!a.command.startsWith(`/${q}`)) - Number(!b.command.startsWith(`/${q}`)));
return commands.filter((c) => new RegExp(`\\b${q.replace(/[^a-z0-9]/g, "")}`, "i").test(c.description));
}
/** "/" command palette for a prompt box: grouped skills and workflows with argument hints, type badges and keyboard navigation. */
export function SlashCommands({
commands = DEFAULT_COMMANDS,
placeholder = "Type / for commands",
onRun,
autoPlay = false,
className,
}: SlashCommandsProps) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const [touched, setTouched] = useState(false);
const [focused, setFocused] = useState(false);
const [view, setView] = useState<View>({ input: "/", active: 0, chosen: null, phase: "pick", progress: 0 });
const demo = autoPlay && !reduce && !touched;
const { ref, t } = useDemoClock(demo, LOOP);
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
useEffect(() => () => {
if (runTimer.current) clearInterval(runTimer.current);
}, []);
const filtered = useMemo(() => filter(commands, view.input), [commands, view.input]);
const ordered = useMemo(
() => GROUPS.flatMap((g) => filtered.filter((c) => (c.category ?? "workflow") === g.id)),
[filtered],
);
const chosen = commands.find((c) => c.command === view.chosen) ?? null;
const args = chosen ? view.input.slice(chosen.command.length).trimStart() : "";
const pick = (cmd: SlashCommandItem) => {
setTouched(true);
setView({ input: `${cmd.command} `, active: 0, chosen: cmd.command, phase: "args", progress: 0 });
};
const run = () => {
if (!chosen) return;
setTouched(true);
onRun?.(chosen, args);
if (runTimer.current) clearInterval(runTimer.current);
setView((v) => ({ ...v, phase: "running", progress: 0 }));
runTimer.current = setInterval(() => {
setView((v) => {
const progress = Math.min(1, v.progress + 0.06);
if (progress >= 1 && runTimer.current) clearInterval(runTimer.current);
return { ...v, progress, phase: progress >= 1 ? "done" : "running" };
});
}, 100);
};
const reset = () => {
setTouched(true);
if (runTimer.current) clearInterval(runTimer.current);
setView({ input: "/", active: 0, chosen: null, phase: "pick", progress: 0 });
};
const onChange = (value: string) => {
setTouched(true);
if (chosen && value.startsWith(chosen.command)) return setView((v) => ({ ...v, input: value }));
setView({ input: value, active: 0, chosen: null, phase: "pick", progress: 0 });
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") return reset();
if (view.phase === "args" && e.key === "Enter") {
e.preventDefault();
return run();
}
if (view.phase !== "pick" || !ordered.length) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const d = e.key === "ArrowDown" ? 1 : -1;
setTouched(true);
setView((v) => ({ ...v, active: (v.active + d + ordered.length) % ordered.length }));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const target = ordered[view.active];
if (target) pick(target);
}
};
const ghost = chosen && view.phase === "args" && !args ? chosen.argsHint : null;
const listId = `${uid}-list`;
const Icon = chosen ? (ICONS[chosen.command] ?? Sparkles) : Terminal;
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Prompt line */}
<div className="flex items-center gap-2.5 bg-gradient-to-b from-muted/50 to-transparent px-3.5 py-3">
<motion.span
key={chosen?.command ?? "none"}
initial={reduce ? false : { scale: 0.6, rotate: -20, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 22 }}
className={cn(
"grid size-7 shrink-0 place-items-center rounded-lg border shadow-xs",
chosen ? "border-foreground bg-foreground text-background" : "bg-background text-muted-foreground",
)}
>
<Icon className="size-3.5" aria-hidden />
</motion.span>
<div className="relative min-w-0 flex-1">
<input
type="text"
role="combobox"
aria-expanded={view.phase === "pick"}
aria-controls={listId}
aria-activedescendant={view.phase === "pick" && ordered[view.active] ? `${uid}-${ordered[view.active].command.slice(1)}` : undefined}
value={view.input}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholder}
className={cn(
"w-full bg-transparent font-mono text-[13px] outline-none placeholder:font-sans placeholder:text-muted-foreground",
!focused && demo && "text-transparent",
)}
/>
<span
className={cn("pointer-events-none absolute inset-0 overflow-hidden font-mono text-[13px] whitespace-pre", focused || !demo ? "text-transparent" : "")}
aria-hidden
>
{chosen ? (
<>
<span className="font-semibold">{chosen.command}</span>
{view.input.slice(chosen.command.length)}
</>
) : (
view.input
)}
{!focused && demo && <span className="sc-caret ml-px inline-block h-[1.05em] w-[1.5px] translate-y-[2px] bg-foreground" />}
{ghost && <span className="text-muted-foreground/60">{ghost}</span>}
</span>
</div>
<AnimatePresence mode="popLayout" initial={false}>
{view.phase === "pick" ? (
<motion.span key="kbd" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="hidden shrink-0 gap-1 sm:flex">
<Kbd>↑↓</Kbd>
<Kbd>↵</Kbd>
</motion.span>
) : (
<motion.button
key="clear"
type="button"
aria-label="Clear command"
onClick={reset}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" aria-hidden />
</motion.button>
)}
</AnimatePresence>
</div>
<AnimatePresence mode="wait" initial={false}>
{view.phase === "pick" ? (
<motion.ul
key="list"
id={listId}
role="listbox"
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6, filter: "blur(2px)" }}
transition={{ duration: 0.25, ease: EASE }}
className="border-t p-1.5"
>
{GROUPS.map((g) => {
const items = ordered.filter((c) => (c.category ?? "workflow") === g.id);
if (!items.length) return null;
return (
<li key={g.id} role="presentation">
<p className="flex items-center gap-1.5 px-2 pt-1.5 pb-1 font-mono text-[10px] tracking-wide text-muted-foreground uppercase">
<span className={cn("size-1.5 rounded-full", g.tone)} />
{g.label}
</p>
<ul role="presentation" className="space-y-px">
{items.map((item) => {
const idx = ordered.indexOf(item);
const on = idx === view.active;
const ItemIcon = ICONS[item.command] ?? Sparkles;
return (
<li key={item.command} id={`${uid}-${item.command.slice(1)}`} role="option" aria-selected={on}>
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setView((v) => ({ ...v, active: idx }))}
onClick={() => pick(item)}
className="relative flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-xs outline-none"
>
{on && (
<motion.span
layoutId={`${uid}-active`}
className="absolute inset-0 rounded-lg bg-muted"
transition={{ type: "spring", stiffness: 600, damping: 40 }}
/>
)}
<span className={cn("relative grid size-7 shrink-0 place-items-center rounded-md border bg-background transition-colors", on && "border-foreground/25 text-foreground")}>
<ItemIcon className={cn("size-3.5", on ? "text-foreground" : "text-muted-foreground")} aria-hidden />
</span>
<span className="relative min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="font-mono font-semibold">{item.command}</span>
{item.argsHint && <span className="font-mono text-[10px] text-muted-foreground">{item.argsHint}</span>}
</span>
<span className="block truncate text-[11px] text-muted-foreground">{item.description}</span>
</span>
<motion.span
animate={{ opacity: on ? 1 : 0, x: on ? 0 : -4 }}
transition={{ duration: 0.18 }}
className="relative shrink-0"
>
<Kbd>↵</Kbd>
</motion.span>
</button>
</li>
);
})}
</ul>
</li>
);
})}
{ordered.length === 0 && <li className="px-3 py-6 text-center text-xs text-muted-foreground">No command matches “{view.input}”</li>}
</motion.ul>
) : (
chosen && (
<motion.div
key="detail"
initial={reduce ? false : { opacity: 0, y: 8, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.3, ease: EASE }}
className="border-t"
>
<div className="px-4 pt-3 pb-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-semibold">{chosen.command}</span>
<TypeBadge category={chosen.category ?? "workflow"} />
</div>
<p className="mt-1 text-xs text-muted-foreground">{chosen.description}</p>
</div>
<dl className="mx-3 mt-2 divide-y rounded-xl border bg-background">
{(chosen.params ?? []).map((p, i) => {
const filled = i === 0 && !!args;
return (
<motion.div
key={p.name}
initial={reduce ? false : { opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.08 + i * 0.06, duration: 0.3, ease: EASE }}
className="flex items-start gap-3 px-3 py-2 text-xs"
>
<dt className="w-20 shrink-0 font-mono text-[11px]">
{p.name}
{p.optional && <span className="text-muted-foreground">?</span>}
</dt>
<dd className="min-w-0 flex-1 text-[11px] text-muted-foreground">
{filled ? (
<motion.span
key={args}
initial={reduce ? false : { opacity: 0.4 }}
animate={{ opacity: 1 }}
className="inline-flex items-center gap-1 rounded bg-foreground/[0.07] px-1 font-mono text-foreground"
>
{args.split(/\s/)[0]}
</motion.span>
) : (
p.hint
)}
</dd>
</motion.div>
);
})}
</dl>
<div className="flex h-12 items-center gap-3 px-4">
<AnimatePresence mode="popLayout" initial={false}>
{view.phase === "args" ? (
<motion.div key="args" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-1 items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">
<Kbd>↵</Kbd> run · <Kbd>esc</Kbd> cancel
</span>
<button
type="button"
onClick={run}
className="h-7 cursor-pointer rounded-md bg-foreground px-3 text-xs font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Run {chosen.command}
</button>
</motion.div>
) : (
<motion.div
key="run"
role="status"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex flex-1 items-center gap-2.5 text-xs"
>
<span
className={cn(
"grid size-5 shrink-0 place-items-center rounded-full transition-colors duration-300",
view.phase === "done" ? "bg-emerald-500 text-white" : "bg-muted",
)}
>
{view.phase === "done" ? (
<motion.span initial={reduce ? false : { scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 600, damping: 15 }}>
<Check className="size-3" strokeWidth={3} aria-hidden />
</motion.span>
) : (
<span className={cn("size-2.5 rounded-full border-[1.5px] border-foreground/30 border-t-foreground", !reduce && "animate-spin")} />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{view.phase === "done" ? `${chosen.command} finished · 3 findings in 2 files` : `Running ${chosen.command}${args ? ` ${args}` : ""}…`}
</span>
<span className="mt-1 block h-1 overflow-hidden rounded-full bg-muted">
<motion.span
className={cn("block h-full rounded-full", view.phase === "done" ? "bg-emerald-500" : "bg-foreground")}
animate={{ width: `${Math.round(view.progress * 100)}%` }}
transition={{ duration: 0.2, ease: "linear" }}
/>
</span>
</span>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)
)}
</AnimatePresence>
</div>
<style>{`
.sc-caret { animation: sc-caret 1s steps(1) infinite; }
@keyframes sc-caret { 50% { opacity: 0; } }
`}</style>
</div>
);
}
function TypeBadge({ category }: { category: NonNullable<SlashCommandItem["category"]> }) {
const g = GROUPS.find((x) => x.id === category) ?? GROUPS[0];
return (
<span className="inline-flex items-center gap-1 rounded-full border bg-background px-1.5 font-mono text-[10px] text-muted-foreground">
<span className={cn("size-1.5 rounded-full", g.tone)} />
{g.label.toLowerCase()}
</span>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return <kbd className="inline-grid min-w-5 place-items-center rounded border bg-background px-1 font-mono text-[10px] leading-4 text-muted-foreground">{children}</kbd>;
}
export default function SlashCommandsDemo() {
return (
<div className="flex h-[380px] w-full items-start justify-center pt-2">
<SlashCommands autoPlay />
</div>
);
}