Skip to content
Elements

Slash commands

Type a slash and the command menu floats above the input, filtering as you continue.

fig. 01

Installation

Install manually
npx shadcn@latest add "@assistant-ui/elements-composer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Typing / at the start of the draft opens a floating menu of commands above the composer; each keystroke narrows the list, and picking one runs it. With a runtime the menu is driven by a trigger primitive wired to a command adapter; standalone you filter a plain list yourself with useSlashMatches.

Getting started

assistant-ui's composer has stable primitives and types for character-activated popovers: a character like / opens a scoped popover with its own search, keyboard navigation, and selection, without touching the rest of the input. The unstable_useSlashCommandAdapter helper and trigger popover context hooks remain unstable.

Define the commands

unstable_useSlashCommandAdapter bundles a command list into the { adapter, action } pair the trigger needs. Each command's execute stays in the hook's closure, so it never has to be serializable:

components/assistant-ui/elements/composer-commands.tsx
"use client";

import { unstable_useSlashCommandAdapter } from "@assistant-ui/react";

export function useComposerCommands() {
  return unstable_useSlashCommandAdapter({
    commands: [
      { id: "summarize", description: "Summarize this thread", icon: "FileText", execute: () => runSummarize() },
      { id: "translate", description: "Translate the last reply", icon: "Languages", execute: () => runTranslate() },
    ],
  });
}

Open the popover on /

components/assistant-ui/elements/composer-bar.tsx
"use client";

import { ComposerPrimitive } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { floating } from "@/components/assistant-ui/elements/surfaces";
import { useComposerCommands } from "./composer-commands";

export function ComposerBar() {
  const slash = useComposerCommands();

  return (
    <ComposerPrimitive.TriggerPopoverRoot>
      <ComposerPrimitive.TriggerPopover char="/" adapter={slash.adapter}>
        <ComposerPrimitive.TriggerPopover.Action
          onExecute={slash.action.onExecute}
          removeOnExecute={slash.action.removeOnExecute}
        />
        <ComposerPrimitive.TriggerPopoverItems>
          {(items) => (
            <div className={cn(floating, "absolute bottom-full z-10 mb-2 w-72 rounded-2xl p-1.5")}>
              {items.map((item, index) => (
                <ComposerPrimitive.TriggerPopoverItem
                  key={item.id}
                  item={item}
                  index={index}
                  className="data-[highlighted]:bg-foreground/[0.04] flex w-full flex-col items-start gap-0.5 rounded-[10px] px-2.5 py-2 text-start outline-none"
                >
                  <span className="text-[13.5px] font-medium">/{item.id}</span>
                  {item.description && <span className="text-muted-foreground text-xs">{item.description}</span>}
                </ComposerPrimitive.TriggerPopoverItem>
              ))}
            </div>
          )}
        </ComposerPrimitive.TriggerPopoverItems>
      </ComposerPrimitive.TriggerPopover>
      <ComposerPrimitive.Root className="flex w-full flex-col gap-2 rounded-[24px] p-2.5">
        <ComposerPrimitive.Input placeholder="Message, or / for commands..." rows={1} className="min-h-11 w-full resize-none bg-transparent px-3 outline-none" />
      </ComposerPrimitive.Root>
    </ComposerPrimitive.TriggerPopoverRoot>
  );
}

TriggerPopoverRoot wraps the whole bar, not only the input; TriggerPopover and ComposerPrimitive.Root sit side by side inside it. The .Action behavior fires onExecute at the moment a command is picked; removeOnExecute defaults to false, which replaces the typed /query with the command's directive text as an audit trail; pass removeOnExecute: true to the adapter to strip the trigger text instead. TriggerPopoverItem sets data-highlighted on whichever row keyboard navigation has reached, so the active tint is a plain data-[highlighted]: class rather than state you track. assistant-ui ships this whole composition pre-built: installing Composer trigger popover gives you the categories, search-empty, and loading states without assembling them by hand.

Anatomy

<div data-slot="composer-menu" data-open={/* true while a "/query" is being typed */}>
  <button data-slot="composer-menu-item" data-active={/* the highlighted row */}>
    {/* icon, /name, description, and a "↵" hint on the active row */}
  </button>
</div>

Standalone, the menu only opens while the draft starts with /; anything typed after the slash is the filter query, matched against the start of each command's name. With a runtime, the trigger opens on a / at the start of the draft or after whitespace, and the query matches anywhere in a command's id, label, or description. ComposerCommandItem's active prop is purely visual (background tint plus the trailing ↵ hint); which row counts as active is state the caller tracks, in both lanes.

Examples

Filtering as you type

The adapter's search is called with the text after the slash on every keystroke; unstable_useSlashCommandAdapter matches against each command's id, label, and description, case-insensitively.

slash.adapter.search("sum"); // → [{ id: "summarize", ... }]

Restyle the menu

Both lanes render into ComposerMenu, which takes className and reads open to animate in from scale-[0.97] opacity-0. ComposerMenuItem's active state is the field surface token; the description text and the ↵ hint each take their own utility classes if you want to hide or re-theme them independently.

<ComposerMenu className="w-96" open={open}>
  {/* items */}
</ComposerMenu>

API reference

ComposerPrimitive (trigger)

PartRendersNotes
TriggerPopoverRootproviderWraps the composer once per bar; groups every trigger char registered inside it.
TriggerPopoverdiv (when open)char="/", adapter; renders nothing until a behavior child registers and the trigger is active.
TriggerPopover.ActionbehavioronExecute(item), formatter?, removeOnExecute? (default false, which leaves the command's directive text in the input).
TriggerPopoverItemsrender prop{(items) => ReactNode}; renders only while a category is active or search mode is on.
TriggerPopoverItembuttonitem, index?; gets data-highlighted under keyboard navigation.
TriggerPopoverCategories / TriggerPopoverCategoryItem / TriggerPopoverBackrender prop / button / buttonOnly needed when the adapter groups commands into categories.

unstable_useSlashCommandAdapter

OptionTypeDescription
commandsUnstable_SlashCommand[]{ id, label?, description?, icon?, execute }.
removeOnExecutebooleanStrips the trigger text when a command is picked, instead of replacing it with directive text. @default false

Returns { adapter, action, iconMap?, fallbackIcon? }; spread action onto TriggerPopover.Action.