AI

Slash Command Menu

A caret-anchored "/" command popup for a textarea you already own — sectioned list, roving keyboard cursor, and one commit that either inserts a token or fires an action.

Preview in your theme

Loading preview…

Registry dependencies
utils
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Slash Command Menu
 *
 * Two pieces that only talk through plain data:
 *
 *   useSlashCommands(...)  owns the textarea it does NOT render — it attaches
 *                          native listeners to the element behind your ref,
 *                          watches the token under the caret, measures where
 *                          the caret is on screen and writes the commit back.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/slash-command-menu.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind slash-command layer for a textarea the
component does NOT render. Ship two exports from one file: a headless hook
useSlashCommands(options) and a presentational <SlashCommandMenu />. The hook
attaches native listeners to the element behind a ref; the menu is a dumb list
fed by a plain data object. No popover library, no Radix, no portal.

Contract
- SlashCommandItem: id, name, hint?, shortcut?, section?, icon?: ReactNode,
  keywords?: string[], insert?: string, disabled?: boolean.
  `insert` present = insert command (the token is replaced by that text);
  `insert` absent = action command (the token is deleted, only onCommand fires).
- SlashCommandSection: { id, label } — declares group order and headings.
- useSlashCommands(options) options:
  textareaRef: RefObject<HTMLTextAreaElement | null> (required, must be mounted),
  items, sections?, trigger = "/", lineStartOnly = false, commitOnTab = true,
  maxQueryLength = 32, appendSpace = true, width = 320, maxHeight = 300,
  gap = 6, disabled = false,
  onCommand?: (item, ctx: { textarea, query, value }) => void.
- It returns { open, query, count, activeItem, select(item), close(), menuProps }.
  menuProps is exactly <SlashCommandMenu />'s data props: { open, query, groups,
  activeId, listboxId, placement, style, onSelectItem, onActivateItem }.
  Spread it: <SlashCommandMenu {...slash.menuProps} />.
- The menu also accepts emptyText, footer (null removes it), label, className and
  the rest of div props. Because its props are plain data, a docs page can pin a
  state open with a literal and no caret at all.

Behavior — the token session
- Scan LEFT from the caret for the trigger. It counts only at a word boundary
  (start of value, or preceded by whitespace; with lineStartOnly, only after a
  newline). Hitting whitespace before the trigger means "not in a token".
- That single rule gives "type a space to dismiss" for free — no special case.
- Stop scanning after maxQueryLength characters so a pasted 2 kB URL with no
  spaces cannot make every keystroke O(line).
- Re-derive the session on input, keyup and pointerup (typing, arrow keys and
  clicks all move the caret) and drop it on blur. A range selection is not a
  caret: selectionStart !== selectionEnd closes the menu.
- Session state is { start, end, query }; setState through an updater that
  returns the previous object when nothing changed, so keyup after every
  keystroke does not re-render the list.

Behavior — claiming keys from a host that already binds them
- Register keydown on the textarea element in the CAPTURE phase. React attaches
  its delegated listeners at the root container, so a capture listener on the
  element runs first: preventDefault + stopPropagation there means the host's
  own onKeyDown never sees the Enter that ran a command, and a surrounding
  dialog never sees the Escape that dismissed the menu. Claim keys ONLY while
  open; everything else (Shift+Enter, ⌘K, the host's send) passes through.
- ArrowDown/ArrowUp move the roving cursor with wraparound. Enter commits (never
  when shiftKey — that is always a newline). Tab commits when commitOnTab. Escape
  dismisses. Nothing else is intercepted.
- Bail out while event.isComposing || keyCode === 229: mid-composition every key
  belongs to the IME, and stealing Enter there eats the candidate confirmation.

Behavior — filtering and the roving cursor
- Five ranked tiers, not a fuzzy score: name prefix (0), word-boundary prefix
  split on [\s\-_./] (1), substring (2), keyword substring (3), subsequence (4);
  no match = filtered out. Ties keep catalogue order. A slash menu is muscle
  memory — the command whose name starts with what was typed must never lose to
  a clever subsequence hit.
- Group the survivors: unsectioned items first, then sections in declared order,
  then undeclared section ids in first-seen order. Score orders rows INSIDE a
  group and never reorders the groups themselves — a section that jumps around
  under the user's thumb destroys the muscle memory the menu exists for.
- The active item is DERIVED (find(preferredId) ?? first enabled ?? null), not
  stored. Typing one more character can delete the highlighted row; deriving
  means the cursor lands on the new first match in the same render instead of
  flashing an empty highlight for a frame.
- Disabled items render dimmed, are skipped by the roving cursor and ignore
  clicks; they still show, because "unavailable on this model" is information.

Behavior — anchoring
- Measure the caret with a hidden mirror: one detached div, created lazily,
  appended to document.body, removed on unmount. Copy the textarea's box-sizing,
  font, letter-spacing, line-height, text-align/indent/transform, padding and
  border widths, and set its width to textarea.offsetWidth; fill it with the
  text before the caret + a marker span + the rest. The marker's offsetTop /
  offsetLeft, minus the field's scroll offsets, is the caret's position.
- Convert to viewport coordinates with getBoundingClientRect and clamp the
  anchor inside the field's box, so a scrolled textarea cannot fling the panel
  off the top or bottom.
- Position the panel `position: fixed` in the style object the hook returns —
  fixed escapes every `overflow: hidden` between the composer and the page
  without a portal. Flip above the caret when the space below cannot host a
  readable panel (~112 px) and there is more room above; clamp left into the
  viewport; cap maxHeight at the remaining room.
- Recompute on input, on window resize, on scroll registered with capture=true
  (scroll does not bubble, so an ancestor scroller needs capture) and through a
  ResizeObserver on the field (an autosizing composer grows under the caret).
  Remove all three on close/unmount.

Behavior — committing
- Replace [start, start + trigger.length + query.length] with item.insert (plus
  one trailing space when appendSpace and the text does not already end in
  whitespace), or with "" for an action command.
- Write through document.execCommand("insertText" | "delete") FIRST: deprecated,
  but it is the only edit that keeps the browser's native undo stack, so Ctrl+Z
  restores the user's "/sum" instead of wiping the draft. Fall back to the
  HTMLTextAreaElement.prototype value setter + a synthetic bubbling `input`
  event — that setter bypasses React's change tracker, which is what makes a
  controlled host actually see the edit.
- Put the caret after the inserted text, then re-assert it once in a
  requestAnimationFrame guarded by "value is still what I wrote": a controlled
  host that re-renders late can otherwise bounce the caret to the end. Cancel
  that frame on unmount.
- Arm the dismissal lock on the token you just wrote: an inserted "/summarize"
  is itself a valid token at a word boundary, and the input event you just
  caused would re-open the menu on your own output.
- Then call onCommand(item, { textarea, query, value }) — the action commands
  (open the file picker, run code) are the consumer's, not yours.

Behavior — one-shot dismissal lock
- Escape stores the current session's start index. While the caret stays inside
  that token the menu refuses to re-open, however many characters are typed;
  the lock clears the moment no token is found under the caret. Without it every
  keystroke after Escape pops the panel back and the key becomes useless.

Accessibility
- The hook does not render the field, so it writes aria-autocomplete="list",
  aria-controls and aria-activedescendant onto it imperatively while open — and
  removes all three on close and on unmount, so no dangling IDREF survives.
- Do NOT put role="combobox" or aria-expanded on a textarea: combobox is for
  single-line inputs and the implicit textbox role does not support
  aria-expanded. aria-autocomplete + aria-controls + aria-activedescendant is
  the supported way to say the same thing.
- Focus never leaves the textarea. Options are role="option" inside a
  role="listbox" with aria-selected; sections are role="group" with an
  aria-hidden heading referenced by aria-labelledby; the keyboard-hint footer is
  aria-hidden (it is redundant noise for a screen reader). With zero matches the
  panel becomes role="status" so the "no match" line is announced.
- preventDefault on the panel's mousedown so a click cannot blur the field
  (blur closes the menu before the click would resolve).
- Keep the active row inside the scroll box with rect math on the list's
  scrollTop, not scrollIntoView — that one may scroll the page and drag the
  composer out from under the user.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the panel,
  bg-accent / text-accent-foreground for the active row, bg-muted for the icon
  chip and the kbd hints, text-muted-foreground for hints, headings and footer,
  border for the frame and separators. No hex, no rgb(), no oklch().
- Matched characters are bolded inside the name instead of tinted, so the
  highlight survives any theme and stays legible on the active row. What gets
  bolded follows the tier that matched: one contiguous run for a prefix, word
  (the word-start occurrence) or substring hit, nothing for a keyword-only hit,
  and subsequence indices grouped into runs only for a tier-4 hit.
- Ship the entrance keyframes with the component through a React 19 hoisted
  <style href precedence> — a 120 ms fade + 4 px slide whose direction follows
  data-placement — and switch it off with motion-reduce:[animation:none]. The
  menu appears, filters and commits identically with animation off.
- Panel is a flex column: the list scrolls (overflow-y-auto, overscroll-contain)
  and the footer stays pinned.

Customization levers
- trigger + lineStartOnly: ">" at line start for macro expansion, "::" for
  emoji, ":" anywhere. The engine is trigger-agnostic; only the boundary rule
  changes.
- Commit keys: drop commitOnTab for editors where Tab must indent; add a key of
  your own by extending the one switch statement in the capture listener.
- Insert semantics: `insert` can be a token the backend parses ("/summarize"),
  a whole prompt template ("Rewrite the draft below…\n"), or nothing at all for
  a pure action. appendSpace=false when the inserted text ends in a newline.
- Row density: drop `hint` to get a one-line row, drop `icon` for a bare list,
  drop `shortcut` if you do not actually bind the keys — a rendered shortcut
  that does nothing is a lie.
- Panel geometry: width / maxHeight / gap are numbers on the hook; anything else
  (radius, shadow, padding) is one className on the menu.
- footer={null} removes the hint row, or pass your own node ("32 commands ·
  ⌘K for everything").
- Ranking: reorder or delete tiers in one function. Swapping tier 4
  (subsequence) for a fuzzy scorer is a local change; keep prefix at the top.
- Containing block: the style is viewport-relative, so if an ancestor has a
  transform/filter (which makes `fixed` behave like `absolute`), render the same
  menu through createPortal(menu, document.body) — no other change.
- Data source: `items` is a plain array, so a remote catalogue is just state;
  the menu re-ranks whenever the array or the query changes.

Concepts

  • Token session — the menu has no "open" button: it exists exactly while a trigger sits at a word boundary with no whitespace between it and the caret. Typing a space, arrowing out or selecting a range ends the session, which is why "space dismisses" costs zero extra code.
  • Capture-phase claim — the host composer already binds Enter to send. A native keydown listener on the element in the capture phase runs before React's delegated root handler, so stopPropagation() lets the menu take Enter/Escape while it is open and hand every other key straight back.
  • Caret mirror — a hidden div wearing the textarea's font, padding and width receives the same text plus a marker span; the marker's offset is the caret's offset. That is the only way to anchor a panel to a caret inside a plain textarea, which has no range geometry API.
  • Derived cursor — the highlighted row is computed from the surviving matches each render, not stored. One more typed character can delete the row you were on; deriving means the highlight moves to the new first match instead of blinking out for a frame.
  • One-shot dismiss lock — Escape remembers which token it dismissed. The panel stays down while the caret remains in that token no matter how much more you type, and re-arms itself on the text a commit just inserted, so the menu never re-opens on its own output.
  • Two commits, one gesture — the same Enter either replaces the token with text (a parsed /summarize, or a whole expanded prompt) or deletes the token and hands control to onCommand, which is how "attach a file" and "run code" live in the same list as prompt macros.

On This Page