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…
- Registry dependencies
- utils
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 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
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.