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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/slash-command-menu.jsonPrompt
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
keydownlistener on the element in the capture phase runs before React's delegated root handler, sostopPropagation()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 toonCommand, which is how "attach a file" and "run code" live in the same list as prompt macros.
Moderation Notice
Content-policy interruption states for chat — blocked input, withheld response and softened answer, with category chips that cross-highlight the flagged passage, a one-shot appeal action, and role=alert only when something was actually stopped.
Dictation Button
A composer mic that turns speech into committed text — toggle or hold-to-talk, an amplitude ring, a live interim chip, and exactly one transcript per session.