components / input
Slash Commands
Type "/" in the Prompt Composer to open a filterable command menu. Arrow keys navigate, Enter or Tab selects, Escape dismisses.
previewslash-commands
install
bash
npx shadcn@latest add https://turnui.xyz/r/slash-commands.jsonprops
| prop | type | default | description |
|---|---|---|---|
| PromptComposer.commands | SlashCommand[] | — | { id, name, description?, keywords?, insert?, icon? }. Name has no leading slash. |
| PromptComposer.onCommand | (command: SlashCommand) => void | — | Called for commands without insert. The typed /query is cleared. |
| SlashCommand.insert | string | — | Replace the input with this text (a prompt template) instead of running the command. |
| CommandMenu | component | — | The listbox on its own, for custom inputs. Takes id, commands, activeIndex, onActiveChange and onSelect. |
| filterCommands(commands, query) | SlashCommand[] | — | Ranks name-prefix matches first, then name substrings, then keywords or description words starting with the query. |
accessibility
- Focus stays in the textarea. Up/Down move through the list (wrapping), Enter or Tab selects, Escape dismisses.
- The input points at the active option with aria-activedescendant and at the list with aria-controls.
- The textarea does not use role="combobox" because that role is not allowed on a textarea.
source
tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type SlashCommand = {
id: string;
/** Name without the leading slash, e.g. "summarize". */
name: string;
description?: string;
/** Extra search terms. */
keywords?: string[];
/** If set, selecting replaces the input with this text instead of running the command. */
insert?: string;
/** Optional leading icon. */
icon?: React.ReactNode;
};
/**
* Ranks name-prefix matches first, then name substrings (2+ characters), then
* keywords or description words that start with the query.
*/
export function filterCommands(commands: SlashCommand[], query: string) {
const q = query.trim().toLowerCase();
if (!q) return commands;
const scored: [SlashCommand, number][] = [];
for (const c of commands) {
const name = c.name.toLowerCase();
if (name.startsWith(q)) scored.push([c, 0]);
else if (q.length > 1 && name.includes(q)) scored.push([c, 1]);
else if (
[...(c.keywords ?? []), ...(c.description ?? "").split(/\s+/)].some((w) => w.toLowerCase().startsWith(q))
) scored.push([c, 2]);
}
return scored.sort((a, b) => a[1] - b[1]).map(([c]) => c);
}
export function optionId(menuId: string, index: number) {
return `${menuId}-option-${index}`;
}
/** The listbox. PromptComposer wires keyboard handling and ARIA; use this directly for custom inputs. */
export function CommandMenu({
id,
commands,
activeIndex,
onActiveChange,
onSelect,
className,
}: {
id: string;
commands: SlashCommand[];
activeIndex: number;
onActiveChange: (index: number) => void;
onSelect: (command: SlashCommand) => void;
className?: string;
}) {
const listRef = React.useRef<HTMLUListElement>(null);
// Keep the active option visible inside the list. scrollIntoView would also scroll the page
// (including on first render), so adjust the list's own scroll position instead.
React.useEffect(() => {
const list = listRef.current;
const option = list?.querySelector<HTMLElement>(`[id="${optionId(id, activeIndex)}"]`);
if (!list || !option) return;
const top = option.offsetTop;
const bottom = top + option.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
}, [activeIndex, id]);
return (
<div className={cn("overflow-hidden rounded border bg-background shadow-lg", className)}>
<ul
ref={listRef}
id={id}
role="listbox"
aria-label="Commands"
className="relative max-h-64 overflow-y-auto p-1"
>
{commands.map((c, i) => (
<li
key={c.id}
id={optionId(id, i)}
role="option"
aria-selected={i === activeIndex}
onMouseMove={() => i !== activeIndex && onActiveChange(i)}
// Keep focus in the textarea while clicking.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(c)}
className={cn(
"flex cursor-default items-center gap-2.5 rounded-sm px-2 py-1.5 text-sm",
i === activeIndex && "bg-muted shadow-[inset_2px_0_0_currentColor]",
)}
>
{c.icon && <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">{c.icon}</span>}
<span className="font-mono text-[13px]">/{c.name}</span>
{c.description && <span className="min-w-0 truncate text-xs text-muted-foreground">{c.description}</span>}
</li>
))}
</ul>
<div aria-hidden className="flex gap-3 border-t px-3 py-1.5 font-mono text-[11px] text-muted-foreground">
<span>↑↓ navigate</span>
<span>↵ select</span>
<span>esc close</span>
</div>
</div>
);
}