Skip to main content
Clever Ops

Slash Command Menu

Anchored slash-command menu for React chat composers, query-prop filtering, grouped commands with argument hints, and listbox semantics for any host input; Tailwind-styled, portal-free.

Preview & code

Loading Slash Command Menu preview

Installation

pnpm dlx shadcn@latest add @cleverui/slash-command-menu

Requires the @cleverui registry in your components.json, a one-off, two-line setup.

Usage

tsx
import { SlashCommandMenu } from '@/components/ui/slash-command-menu'
Exampletsx
'use client'

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react'
import {
  SlashCommandMenu,
  type SlashCommand,
} from '@/components/ui/slash-command-menu'

const COMMANDS: SlashCommand[] = [
  {
    name: 'reconcile',
    description: 'Match invoices against Xero for a period',
    args: '<org> --since <date>',
    group: 'ledger',
  },
  {
    name: 'trial-balance',
    description: 'Run the trial balance and surface exceptions',
    args: '--period <month>',
    group: 'ledger',
  },
  {
    name: 'refund-review',
    description: 'List Stripe refunds awaiting approval',
    args: '--status open',
    group: 'payments',
  },
  {
    name: 'charge-lookup',
    description: 'Find a Stripe charge by id or amount',
    args: '<id|amount>',
    group: 'payments',
  },
  {
    name: 'digest',
    description: 'Draft the weekly ops digest for #ops',
    args: '--channel <name>',
    group: 'comms',
  },
  {
    name: 'escalate',
    description: 'Surface stalled Linear tickets',
    args: '<team> --days <n>',
    group: 'comms',
  },
  {
    name: 'spawn-verify',
    description: 'Spin a verification sub-agent on exceptions',
    args: '<run-id>',
    group: 'agents',
  },
  {
    name: 'memory-write',
    description: 'Pin a fact to the session ledger',
    args: '<key> <value>',
    group: 'agents',
  },
]

function extractSlashQuery(value: string): string | null {
  // active slash command: last token starting with /
  const match = value.match(/(?:^|\s)\/([^\s]*)$/)
  if (!match) return null
  return match[1] ?? ''
}

export default function SlashCommandMenuDemo() {
  const listboxId = useId().replace(/:/g, '') + '-slash'
  const inputRef = useRef<HTMLTextAreaElement>(null)
  const [value, setValue] = useState('/')
  const [activeIndex, setActiveIndex] = useState(0)
  const [note, setNote] = useState<string | null>(null)

  const query = useMemo(() => extractSlashQuery(value), [value])
  const open = query !== null

  const filtered = useMemo(() => {
    if (query === null) return []
    const q = query.trim().toLowerCase()
    if (!q) return COMMANDS
    return COMMANDS.filter(
      (c) =>
        c.name.toLowerCase().includes(q) ||
        (c.description?.toLowerCase().includes(q) ?? false) ||
        (c.group?.toLowerCase().includes(q) ?? false)
    )
  }, [query])

  useEffect(() => {
    setActiveIndex(0)
  }, [query])

  useEffect(() => {
    if (filtered.length === 0) return
    setActiveIndex((i) => Math.min(i, filtered.length - 1))
  }, [filtered.length])

  const insertCommand = useCallback(
    (name: string) => {
      setValue((prev) => {
        const replaced = prev.replace(/(?:^|\s)\/[^\s]*$/, (m) => {
          const lead = m.startsWith(' ') || m.startsWith('\n') ? m[0] : ''
          return `${lead}/${name} `
        })
        return replaced.endsWith(`/${name} `) ? replaced : `/${name} `
      })
      setNote(`inserted /${name}`)
      requestAnimationFrame(() => inputRef.current?.focus())
    },
    []
  )

  const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
    if (!open || filtered.length === 0) return
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      setActiveIndex((i) => (i + 1) % filtered.length)
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      setActiveIndex((i) => (i - 1 + filtered.length) % filtered.length)
    } else if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      const cmd = filtered[activeIndex]
      if (cmd) insertCommand(cmd.name)
    } else if (e.key === 'Escape') {
      e.preventDefault()
      setValue((prev) => prev.replace(/(?:^|\s)\/[^\s]*$/, '').trimEnd())
    }
  }

  const activeDescendant =
    open && filtered.length > 0
      ? `${listboxId}-option-${activeIndex}`
      : undefined

  return (
    <div className="mx-auto flex min-h-[480px] w-full max-w-lg flex-col justify-end gap-3 px-4 py-12 sm:px-6 sm:py-16">
      <div className="rounded-2xl border border-border/60 bg-card px-4 py-3 sm:px-5">
        <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
          meridian-ops · composer
        </p>
        <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
          Type <span className="font-mono text-foreground">/</span> for
          ledger and payment commands. Arrow keys move the highlight; Enter
          inserts.
        </p>
        {note && (
          <p className="mt-2 font-mono text-[11px] text-muted-foreground">
            {note}
          </p>
        )}
      </div>

      <div className="relative w-full min-w-0">
        {open && (
          <div className="mb-2 w-full">
            <SlashCommandMenu
              query={query}
              commands={COMMANDS}
              activeIndex={activeIndex}
              onActiveIndexChange={setActiveIndex}
              onSelect={insertCommand}
              listboxId={listboxId}
            />
          </div>
        )}

        <div className="rounded-2xl border border-border/60 bg-background px-3 py-2.5">
          <textarea
            ref={inputRef}
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={onKeyDown}
            rows={2}
            placeholder="Message Meridian…"
            aria-label="Composer"
            role="combobox"
            aria-expanded={open}
            aria-controls={open ? listboxId : undefined}
            aria-activedescendant={activeDescendant}
            aria-autocomplete="list"
            className="w-full resize-none bg-transparent text-sm leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden"
          />
          <div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-2">
            <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
              gpt-4.1-mini
            </span>
            <span className="font-mono text-[11px] text-muted-foreground">
              ↵ select · esc dismiss
            </span>
          </div>
        </div>
      </div>
    </div>
  )
}

API reference

SlashCommandMenu accepts the following props.

PropTypeDefaultDescription
query*stringtext after '/' in the host input; empty string shows everything
commands*SlashCommand[]command set; args render as mono usage hints
activeIndexnumbercontrolled highlight index into the filtered list
onActiveIndexChange(index: number) => voidfires when hover or focus moves the highlight
onSelect(name: string) => voidfires with the chosen command name on click
listboxIdstringid on the listbox for host aria-activedescendant
emptyLabelstring'No matching commands'quiet mono line when the filter clears everything

…plus everything from Omit< React.HTMLAttributes<HTMLDivElement>, 'onSelect' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' >: className, event handlers, aria attributes and the rest pass straight through.

Referenced types

Typestsx
export interface SlashCommand {
  name: string
  description?: string
  /** verbatim mono usage hint, e.g. '<env> [--force]' */
  args?: string
  group?: string
}

Frequently asked questions

How do I install the Slash Command Menu component?

Register the @cleverui registry in your components.json ("@cleverui": "https://cleverops.com.au/r/{name}.json"), then run `npx shadcn@latest add @cleverui/slash-command-menu`. The shadcn CLI copies the source into your project, you own the code from that point. You can also copy the source directly from this page.

What dependencies does Slash Command Menu require?

Slash Command Menu uses motion on top of React and Tailwind CSS. You don't need to install it manually, the shadcn CLI resolves and installs npm dependencies automatically when you add the component.

Can I theme Slash Command Menu to match my brand?

Yes. The component is styled entirely with Tailwind utilities over standard design tokens (primary, background, muted, border), so it inherits your existing shadcn/ui theme automatically. Change your CSS variables and the component follows, no source edits needed for palette, radius, or fonts.

Want this built for you?

CleverUI is built by the Clever Ops web team. If you'd rather ship a hand-coded, fast, custom website than assemble one, we build them for Australian businesses every week.

Explore web development services