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…

"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 (subsequence indices, grouped
  into runs) instead of tinted, so the highlight survives any theme and stays
  legible on the active row.
- 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