nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsSlash commands
Copy promptCopy prompt

Customize

Placeholder text in the filter input

Loop a scripted slash-command filter demo

JSX · copied with Copy prompt

<SlashCommands autoPlay />

Slash commandsFreeNew

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.

Installation

Free to install: no account, access key or components.json setup needed.

$pnpm dlx shadcn@latest add https://nextui.dev/r/slash-commands.json

Usage

1"use client";
2
3import { SlashCommands } from "@/components/nextui/slash-commands";
4
5export function SkillPalette() {
6 return (
7 <SlashCommands
8 onRun={(cmd) => console.log("Triggered skill:", cmd.command)}
9 />
10 );
11}

API reference

Edit props live in the preview above, or see all 5 props
PropTypeDefaultDescription
commandsSlashCommandItem[]–Available /commands with argsHint, description and category
placeholderstring"Type / for commands"Placeholder text in the filter input
onRun(command: SlashCommandItem, args: string) => void–Called when a slash command is selected
autoPlaybooleanfalseLoop a scripted slash-command filter demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/slash-commands.tsx
"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>
  );
}
Sign in