MK
Building a Slash Menu Without Touching the Schema

Building a Slash Menu Without Touching the Schema

April 17, 2026

In my last post, I talked about choosing ProseMirror over Tiptap, Slate, and friends. The TLDR was: when your editor is the product, understanding the engine beats having a nice wrapper. What I didn't talk about was what happens after you make that choice — when you sit down to build features and realize ProseMirror's documentation, while thorough, has exactly zero opinions about how to build the interactive UI patterns that users now expect from every editor.

The slash menu is one of those patterns. Type /, see a filterable dropdown, pick an item, and a block appears. Notion popularized it. Every block editor now has one. It seems simple. It is not.

The Naive Approach: A Custom Schema Node

When most people first think about implementing a slash menu, the instinct is to model the trigger as part of the document. "The user typed / — that's a special node! Let me add a slashTrigger node to my schema."

// Don't do this
const schema = new Schema({
  nodes: {
    // ...
    slashTrigger: {
      group: 'inline',
      inline: true,
      atom: true,
      attrs: { query: { default: '' } },
      toDOM: () => ['span', { class: 'slash-trigger' }, '/'],
      parseDOM: [{ tag: 'span.slash-trigger' }],
    },
  },
})

This works for about fifteen minutes. Then you discover the problems:

Schema pollution. Your document model now has a node type that exists purely for UI purposes. If you serialize the document to JSON for storage or collaboration, you're shipping ephemeral UI state into your data layer. Every consumer of your document — mobile clients, server-side renderers, export pipelines — now needs to know what a slashTrigger is and how to handle it. Hope you enjoy writing migration code.

Collaboration nightmares. Schema nodes are part of the synced document. That means every collaborator sees your slashTrigger atom appear, watches its query attribute mutate character by character as you type, and then watches it either get replaced or awkwardly deleted. Two users type / at the same time? Now there are two slashTrigger atoms in the document fighting for attention, and the CRDT has to merge both of them into a coherent state.

Undo becomes weird. With a schema node, every keystroke updates the slashTrigger atom's query attribute — those are attribute-change transactions, not normal text insertions. If the user dismisses the menu, the atom has to be removed and replaced with regular text, which is another undo step that makes no semantic sense to the user. With the plugin-state approach, the user is just typing regular text the whole time. Dismiss the menu and the text stays — nothing special happened from the document's perspective. Undo works exactly like undo always works: it removes the characters you typed, in order, because that's what they are.

Cursor management hell. Inline atoms have opinions about cursor behavior. ProseMirror needs to know whether the cursor should go before or after the atom, what happens when you arrow-key through it, what happens on backspace. I figured it would take too long to wrestle with edge cases that wouldn't exist if the trigger wasn't in the document at all.

The Right Approach: Plugin State + Decorations

ProseMirror has two mechanisms specifically designed for putting UI on top of the document without modifying it: plugin state and decorations. Together, they're how you build any interactive overlay that's anchored to document positions but not part of the document itself.

Here's the architecture:

  1. Plugin state tracks whether the menu is active, where the / was typed, what the user has typed since, and which positions have been dismissed
  2. Inline decorations visually highlight the / + query text without adding nodes to the document
  3. A React component (or whatever your framework is) reads the plugin state and renders the dropdown

Here's the key insight: while the slash menu is active, the text you're typing doesn't leave your machine. The / and the query characters after it exist in your local ProseMirror state, but the collaboration layer doesn't sync them. Your collaborators see nothing.

The mechanism is simple. Every dispatched transaction flows through dispatchTransaction, where the new editor state gets applied locally. The Yjs binding layer — which is responsible for pushing local changes to the shared Y.Doc — checks the slash menu plugin's state on every transaction:

dispatchTransaction(transaction) {
  const newState = view.state.apply(transaction);
  view.updateState(newState);

  // The Yjs binding checks slashMenuPluginKey.getState(newState)?.active
  // and skips Y.Doc sync while the slash menu is open.

  onSlashMenuTransaction(newState);
}

That's it. The plugin state's active field — a plain boolean set by the activation and query-tracking logic — becomes the gate that tells the collaboration layer "hold on, the user is mid-interaction, don't sync this yet." The local ProseMirror doc updates normally (the user sees their /heading text, the decoration highlights it, the dropdown filters), but the Yjs binding skips propagation until active flips back to false.

So here's what the collaboration timeline actually looks like:

  1. You type /. Your local plugin activates — active becomes true. The Yjs binding sees this and suppresses sync. Your collaborators see nothing
  2. You keep typing heading. Each keystroke updates your local doc and the plugin's query. The binding keeps suppressing. Still nothing on the other end
  3. You pick "Heading 1". The executor deletes /heading, inserts a heading node, and the plugin deactivates — active becomes false. Now the binding syncs the accumulated changes. Your collaborators see a heading appear, which is exactly what they should see

And if you dismiss instead? The plugin deactivates, active becomes false, and the binding syncs the /heading text as regular document content. Your collaborators see some text appear.

This is the collaboration win that the schema-node approach can never achieve. With a custom slashTrigger node, every mutation is part of the document and syncs immediately — your collaborators watch your query being assembled character by character in real time. With plugin state gating the sync layer, they see either the final result (a heading) or the dismissed text (/heading).

The Plugin State Shape

interface SlashMenuPluginState {
  active: boolean
  slashPos: number           // document position of "/"
  query: string              // text after "/" (e.g. "head" for "/head")
  decorationSet: DecorationSet
  dismissedPositions: number[] // prevent re-triggering after dismiss
}

Every field here is local-only. The active boolean is the one the Yjs binding reads to decide whether to suppress sync. The query and decorationSet drive the UI. None of it touches the shared Y.Doc.

The dismissedPositions array handles the aftermath. When the user presses Escape or Space to dismiss the menu, active flips to false, the Yjs binding resumes sync, and the buffered text gets flushed to collaborators as regular content. Now the / character is sitting in the real synced document — and you don't want the plugin to see it and immediately reactivate. So you track positions where the menu was explicitly dismissed and skip them on future transaction scans. These positions get mapped through every subsequent transaction so they stay accurate as the document changes.

Activation Logic

The plugin's apply method runs on every transaction. It needs to answer: "should the menu be active right now?" The logic works in two phases — trying to activate when inactive, and revalidating when already active.

function tryActivate(
  state: EditorState,
  prevState: SlashMenuPluginState,
): SlashMenuPluginState | null {
  const { selection } = state
  if (!selection.empty) return null

  const $pos = selection.$from
  const textBefore = $pos.parent.textBetween(
    Math.max(0, $pos.parentOffset - 1),
    $pos.parentOffset,
  )

  if (textBefore !== '/') return null

  const slashPos = $pos.pos - 1

  // Don't activate at dismissed positions
  if (prevState.dismissedPositions.includes(slashPos)) return null

  // Only activate at block start or after whitespace
  if ($pos.parentOffset > 1) {
    const charBefore = $pos.parent.textBetween(
      $pos.parentOffset - 2,
      $pos.parentOffset - 1,
    )
    if (charBefore !== ' ' && charBefore !== '\u00A0') return null
  }

  return activeState(slashPos, '', createDecoration(slashPos, slashPos + 1))
}

Note the whitespace check. The menu only activates when / appears at the start of a block or after a space. This prevents false positives when someone types a URL or a file path. It's a small heuristic, but it eliminates an entire category of "why did the slash menu just appear while I was typing https://" bug reports.

Revalidation: When the Menu Is Already Open

Once active, the plugin needs to keep up with what the user types. Every local keystroke updates the query in the local document — still held back from sync — and produces a transaction. Remote transactions arrive too, because a collaborator typing elsewhere in the document shifts positions everywhere. The plugin's apply method runs on all of them. For local transactions, it re-reads the text after the / to update the query. For remote transactions, it maps the stored slashPos through the position mapping so the menu stays anchored correctly.

function revalidateActive(
  tr: Transaction,
  prev: SlashMenuPluginState,
  newState: EditorState,
): SlashMenuPluginState {
  // Map slashPos through the transaction's mapping
  const mappedSlashPos = tr.mapping.map(prev.slashPos)

  // If cursor moved away from the query range, deactivate
  const { selection } = newState
  if (selection.from < mappedSlashPos || !selection.empty) {
    return inactiveState(prev.dismissedPositions)
  }

  // Recalculate query from document text
  const $pos = selection.$from
  const textFromSlash = $pos.parent.textBetween(
    $pos.parent.childBefore(mappedSlashPos - $pos.start()).offset
      - ($pos.start() - mappedSlashPos),
    $pos.parentOffset,
  )

  // Query is everything after the "/"
  const query = textFromSlash.slice(1)

  // Dismiss on double-space or space with no matches
  if (query.endsWith('  ') || (query.endsWith(' ') && !hasMatches(query))) {
    return inactiveState([...prev.dismissedPositions, mappedSlashPos])
  }

  return activeState(
    mappedSlashPos,
    query,
    createDecoration(mappedSlashPos, mappedSlashPos + 1 + query.length),
  )
}

Position mapping via tr.mapping.map() is critical here. When a collaborator inserts text above your cursor, every position below shifts. When they delete a paragraph, positions collapse. ProseMirror's mapping system handles this automatically — but only if you actually use it. Skip the mapping and your menu points at the wrong character the moment a remote edit lands. Map your positions. Always.

The Decoration

The inline decoration highlights the / + query text without modifying the document:

function createDecoration(from: number, to: number): DecorationSet {
  return DecorationSet.create(doc, [
    Decoration.inline(from, to, {
      class: 'slash-command-active',
      nodeName: 'span',
    }),
  ])
}

That's it. A CSS class on a span that wraps existing text. No schema nodes, no atoms, no cursor management. The decoration is recreated on every relevant transaction — cheap, stateless, disposable. If the plugin deactivates, the decoration vanishes because the new state returns DecorationSet.empty.

Executing a Slash Command

When the user picks an item from the dropdown, the executor needs to:

  1. Delete the locally-held / + query text
  2. Insert the selected block at the right position
  3. Place the cursor inside the new block
  4. Dispatch the transaction — this is the moment the change actually syncs to collaborators
function executeSlashItem(
  item: SlashMenuItem,
  pluginKey: PluginKey,
  view: EditorView,
): void {
  const pluginState = pluginKey.getState(view.state)
  if (!pluginState?.active) return

  const { slashPos, query } = pluginState

  // Start transaction: delete "/" + query (local text that hasn't synced yet)
  let tr = view.state.tr.delete(slashPos, slashPos + 1 + query.length)

  const hasTextBefore = slashPos > view.state.doc.resolve(slashPos).start()

  // Let the item do its thing
  const result = item.execute({
    tr,
    hasTextBefore,
    schema: view.state.schema,
  })

  if (result === 'blur') {
    view.dispatch(tr)
    view.dom.blur()
  } else if (result) {
    view.dispatch(tr.scrollIntoView())
    view.focus()
  }
}

The dispatch call at the end is the moment the change syncs — the plugin deactivates, the Yjs binding sees active: false, and the transaction propagates. From a collaborator's perspective, a heading simply appears where there was nothing before.

The hasTextBefore flag matters because it determines insertion strategy. If the / was the only content in the block, the executor can replace the entire empty block. If there was text before the / (like "some text /"), it needs to insert after instead.

Block Insertion: Harder Than It Looks

Inserting a block sounds simple until you remember that ProseMirror documents are trees, not flat lists. The cursor might be inside a list item, inside a blockquote, inside a table cell. You can't just shove a heading into a table cell — the schema won't allow it.

I built two utilities to handle this: replaceEmptyBlock for when the current block is empty, and insertAfterBlock for when it's not. Both rely on findInsertionDepth, which walks up the document tree to find the shallowest ancestor where the new node type is actually allowed:

function findInsertionDepth(tr: Transaction, nodeType: NodeType): number {
  const $pos = tr.selection.$from
  for (let depth = $pos.depth; depth >= 1; depth--) {
    const parent = $pos.node(depth)
    const index = $pos.index(depth)
    if (parent.canReplaceWith(index, index, nodeType)) {
      return depth
    }
  }
  return 1 // fallback to doc level
}

This means if you type /columns inside a list item, the executor will walk up past the list item, past the list, until it finds a depth where column_layout is allowed (usually the doc level). It then inserts the column layout after the list, not inside it. The schema stays valid and the user gets what they expected.

replaceEmptyBlock has its own trick for nested structures. If the cursor is in an empty paragraph that's the only child of a list item that's the only child of a list — it replaces the entire list, not just the paragraph. This prevents orphaned wrapper nodes:

function replaceEmptyBlock(
  tr: Transaction,
  nodeToInsert: Node,
  cursorOffset: number,
): boolean {
  const $pos = tr.selection.$from

  // Walk up while parent has exactly one child
  let deleteDepth = $pos.depth
  while (
    deleteDepth > 1 &&
    $pos.node(deleteDepth - 1).childCount === 1
  ) {
    deleteDepth--
  }

  const from = $pos.before(deleteDepth)
  const to = $pos.after(deleteDepth)

  tr.replaceWith(from, to, nodeToInsert)
  tr.setSelection(TextSelection.create(tr.doc, from + cursorOffset))

  return true
}

Variant Composition: One Slash Menu, Five Editors

Here's where the architecture pays off. I plan on shipping five editor variants — a full Notion-style page editor, a medium-weight task description editor, a lightweight comment editor, a whiteboard text editor, and an invoice field editor. Each one has a different set of available block types, which means each one needs a different slash menu.

The first instinct would be a single slash menu plugin with a giant if (variant === 'full') block. The approach I actually used: each variant composes its own SlashMenuItem[] array from a shared library of item factories.

// Item factories — schema-aware, return empty array if node type doesn't exist
function createHeadingItems(schema: Schema): SlashMenuItem[] {
  const items: SlashMenuItem[] = []
  if (schema.nodes.heading1) {
    items.push({
      id: 'heading-1',
      label: 'Heading 1',
      keywords: ['h1', 'title', 'big'],
      icon: 'heading1',
      group: 'headings',
      execute: makeHeadingExecutor('heading1'),
    })
  }
  // heading2, heading3, heading4...
  return items
}

function createColumnLayoutItems(schema: Schema): SlashMenuItem[] {
  if (!schema.nodes.columnLayout) return []
  return [2, 3, 4, 5].map((cols) => ({
    id: `columns-${cols}`,
    label: `${cols} Columns`,
    keywords: ['column', 'layout', 'grid', 'side'],
    icon: `columns${cols}`,
    group: 'layout',
    execute: makeColumnLayoutExecutor(cols),
  }))
}

Each factory checks whether the relevant node type exists in the schema. If the medium editor's schema doesn't include columnLayout, createColumnLayoutItems returns an empty array. No conditional logic needed at the composition site:

// Full editor — everything
function fullPlugins(): Plugin[] {
  return [
    history(),
    fullKeymap(),
    slashMenuPlugin({
      items: [
        ...createHeadingItems(fullWebSchema),
        ...createListItems(fullWebSchema),
        ...createChecklistItems(fullWebSchema),
        ...createCalloutItems(fullWebSchema),
        ...createTableItems(fullWebSchema),
        ...createDividerItems(fullWebSchema),
        ...createTabsItems(fullWebSchema),
        ...createToggleItems(fullWebSchema),
        ...createColumnLayoutItems(fullWebSchema),
      ],
    }),
  ]
}

// Medium editor — subset, no columns/tabs/toggles
function mediumPlugins(): Plugin[] {
  return [
    history(),
    mediumKeymap(),
    slashMenuPlugin({
      items: [
        ...createHeadingItems(mediumWebSchema),
        ...createListItems(mediumWebSchema),
        ...createChecklistItems(mediumWebSchema),
        ...createCalloutItems(mediumWebSchema),
        ...createTableItems(mediumWebSchema),
      ],
    }),
  ]
}

The small (comment) editor doesn't get a slash menu at all. The invoice editor doesn't either — its "blocks" are fixed-structure fields where inserting arbitrary content would break the invoice layout. The whiteboard editor gets its own canvas-adapted variant with items that make sense for shape text.

No if statements. No runtime variant detection. Each editor knows exactly what it can do at build time, because the schema and the slash items are composed from the same source of truth.

Filtering: Why Keywords Matter

The filter function is intentionally simple — split the query into words, check if every word appears somewhere in the item's label + keywords:

function filterSlashItems(
  items: SlashMenuItem[],
  query: string,
): SlashMenuItem[] {
  if (!query) return items

  const words = query.toLowerCase().split(/\s+/).filter(Boolean)
  return items.filter((item) => {
    const haystack = [item.label, ...item.keywords]
      .join(' ')
      .toLowerCase()
    return words.every((word) => haystack.includes(word))
  })
}

The keywords array on each item is what makes this work in practice. A user might type /h1 or /title or /big expecting to get a heading. They might type /todo expecting a checklist. Without keywords, you'd need exact label matching, which means users who don't remember your exact label names are out of luck.

This is also why the dismissal-on-space logic exists. If the user types /asdfgh and gets no matches, pressing Space dismisses the menu and lets them keep typing. The /asdfgh stays as text — weird text, but their text. The menu won't re-trigger for that / because the position is tracked in dismissedPositions.

The React Layer: Reading Plugin State

The React component that renders the dropdown is pure reader. It doesn't own state — it reads it from the ProseMirror plugin:

function SlashMenuDropdown({ view }: { view: EditorView }) {
  const pluginState = slashMenuPluginKey.getState(view.state)

  if (!pluginState?.active) return null

  const items = filterSlashItems(allItems, pluginState.query)
  if (items.length === 0) return null

  // Position dropdown near the slash character
  const coords = view.coordsAtPos(pluginState.slashPos)

  return (
    <Popover style={{ top: coords.bottom, left: coords.left }}>
      {items.map((item) => (
        <SlashMenuOption
          key={item.id}
          item={item}
          onSelect={()=> executeSlashItem(item, slashMenuPluginKey, view)}
        />
      ))}
    </Popover>
  )
}

This separation is deliberate. The plugin handles when and what, the React component handles how it looks. If I swapped React for Svelte tomorrow (I won't, but hypothetically), the plugin wouldn't change at all. The dropdown is just a different consumer of the same plugin state.

It also means the slash menu works identically across all five editor variants despite each variant having completely different React wrapper components. The FullEditor, MediumEditor, and WhiteboardEditor components all read the same slashMenuPluginKey — they just see different items because each variant composed different items into the plugin.

Trade-offs I Accept

This architecture isn't free. Let me be honest about the costs.

More code than Tiptap's SlashCommand extension. Tiptap has this as a community extension where you pass a list of items and get a working menu. My implementation is a few hundred lines of plugin state management, decorator creation, command execution, and insertion utilities. If you're prototyping, Tiptap wins on time-to-first-slash-menu by a landslide.

No schema-level validation of slash results. Because the slash trigger isn't in the schema, there's no compile-time guarantee that every executor produces valid content. An executor could theoretically create a node that the schema rejects, and you'd get a runtime error. In practice, the item factories are schema-aware (they check schema.nodes.whatever before creating items), so this hasn't bitten me. But it's a theoretical gap. I plan on enforcing schema-level validation once the five editor variants approach their release.

Position tracking requires discipline. Every stored position — slashPos, dismissedPositions — must be mapped through transactions. Miss one mapping and you get phantom menus or menus that refuse to appear. ProseMirror's Mapping makes this straightforward, but it's something you have to remember every time you touch the plugin's apply method.

The activation heuristic isn't perfect. The "start of block or after whitespace" check covers 99% of real usage, but it'll miss if someone wants to trigger the menu mid-word. I decided this is fine — mid-word slash triggers would cause more false positives than they'd solve. But it's a product decision baked into the code.

The Payoff

What I get back: a slash menu that works across five editor variants with zero conditional logic, survives collaborative editing without leaking UI state to other clients, doesn't pollute the undo stack, and renders its dropdown through whatever UI framework I want. The same pattern — plugin state + decorations + external UI — powers the active line highlight, placeholder text, collaborative cursors, and every other "visible but not in the document" feature in the editor.

ProseMirror gives you decorations and plugin state fields, and trusts you to compose them into whatever your product needs. The upfront cost is real — more code than a Tiptap extension, more concepts to internalize, more discipline around position mapping. The long-term payoff is an implementation you fully understand, with no hidden assumptions and no framework-specific weirdness.