diff --git a/vanilla-slash-menu/.gitignore b/vanilla-slash-menu/.gitignore
new file mode 100644
index 000000000..5d6225c6d
--- /dev/null
+++ b/vanilla-slash-menu/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.next
+.svelte-kit
diff --git a/vanilla-slash-menu/README.md b/vanilla-slash-menu/README.md
new file mode 100644
index 000000000..0efff88c1
--- /dev/null
+++ b/vanilla-slash-menu/README.md
@@ -0,0 +1,15 @@
+# vanilla-slash-menu
+
+A [ProseKit](https://prosekit.dev) example.
+
+[](https://stackblitz.com/github/prosekit/examples/tree/master/vanilla-slash-menu)
+[](https://codesandbox.io/p/sandbox/github/prosekit/examples/tree/master/vanilla-slash-menu)
+
+Run the example locally with:
+
+```bash
+npx degit prosekit/examples/vanilla-slash-menu vanilla-slash-menu
+cd vanilla-slash-menu
+npm install
+npm run dev
+```
diff --git a/vanilla-slash-menu/index.html b/vanilla-slash-menu/index.html
new file mode 100644
index 000000000..2d9080a6a
--- /dev/null
+++ b/vanilla-slash-menu/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ ProseKit + Vanilla TypeScript
+
+
+
+
+
diff --git a/vanilla-slash-menu/package.json b/vanilla-slash-menu/package.json
new file mode 100644
index 000000000..5d4ca7193
--- /dev/null
+++ b/vanilla-slash-menu/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "example-vanilla-slash-menu",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "prosekit": "^0.17.1"
+ },
+ "devDependencies": {
+ "@egoist/tailwindcss-icons": "^1.9.0",
+ "@iconify-json/lucide": "^1.2.84",
+ "@tailwindcss/vite": "^4.1.18",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "vite": "7.3.1"
+ }
+}
diff --git a/vanilla-slash-menu/src/app.css b/vanilla-slash-menu/src/app.css
new file mode 100644
index 000000000..5d0567ecf
--- /dev/null
+++ b/vanilla-slash-menu/src/app.css
@@ -0,0 +1,13 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@plugin "@egoist/tailwindcss-icons";
+
+body {
+ height: 100svh;
+ display: grid;
+ max-width: 900px;
+ padding: 16px;
+ margin-left: auto;
+ margin-right: auto;
+}
diff --git a/vanilla-slash-menu/src/components/editor/examples/slash-menu/editor.ts b/vanilla-slash-menu/src/components/editor/examples/slash-menu/editor.ts
new file mode 100644
index 000000000..2e28e149c
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/examples/slash-menu/editor.ts
@@ -0,0 +1,39 @@
+import 'prosekit/basic/style.css'
+import 'prosekit/basic/typography.css'
+
+import { createEditor } from 'prosekit/core'
+
+import { renderSlashMenu } from '../../ui/slash-menu'
+
+import { defineExtension } from './extension'
+
+export function setupVanillaEditor() {
+ const extension = defineExtension()
+ const editor = createEditor({ extension })
+
+ return {
+ render: () => {
+ const port = document.createElement('div')
+ port.className =
+ 'box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white'
+
+ const scrolling = document.createElement('div')
+ scrolling.className = 'relative w-full flex-1 box-border overflow-y-auto'
+ port.append(scrolling)
+
+ const content = document.createElement('div')
+ content.className =
+ 'ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500'
+ scrolling.append(content)
+
+ scrolling.append(renderSlashMenu(editor))
+
+ editor.mount(content)
+
+ return port
+ },
+ destroy: () => {
+ editor.unmount()
+ },
+ }
+}
diff --git a/vanilla-slash-menu/src/components/editor/examples/slash-menu/extension.ts b/vanilla-slash-menu/src/components/editor/examples/slash-menu/extension.ts
new file mode 100644
index 000000000..0edda6013
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/examples/slash-menu/extension.ts
@@ -0,0 +1,12 @@
+import { defineBasicExtension } from 'prosekit/basic'
+import { union } from 'prosekit/core'
+import { definePlaceholder } from 'prosekit/extensions/placeholder'
+
+export function defineExtension() {
+ return union(
+ defineBasicExtension(),
+ definePlaceholder({ placeholder: 'Press / for commands...' }),
+ )
+}
+
+export type EditorExtension = ReturnType
diff --git a/vanilla-slash-menu/src/components/editor/examples/slash-menu/index.ts b/vanilla-slash-menu/src/components/editor/examples/slash-menu/index.ts
new file mode 100644
index 000000000..e54daaa4f
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/examples/slash-menu/index.ts
@@ -0,0 +1 @@
+export { setupVanillaEditor } from './editor'
diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/index.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/index.ts
new file mode 100644
index 000000000..862a5dee5
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/index.ts
@@ -0,0 +1 @@
+export { renderSlashMenu } from './slash-menu'
diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-empty.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-empty.ts
new file mode 100644
index 000000000..ea2d45423
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-empty.ts
@@ -0,0 +1,17 @@
+import 'prosekit/web/autocomplete'
+
+import type { AutocompleteEmptyElement } from 'prosekit/web/autocomplete'
+
+export function renderSlashMenuEmpty() {
+ const empty = document.createElement(
+ 'prosekit-autocomplete-empty',
+ ) as AutocompleteEmptyElement
+ empty.className =
+ 'relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800'
+
+ const span = document.createElement('span')
+ span.textContent = 'No results'
+ empty.append(span)
+
+ return empty
+}
diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-item.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-item.ts
new file mode 100644
index 000000000..7730603ff
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-item.ts
@@ -0,0 +1,34 @@
+import 'prosekit/web/autocomplete'
+
+import type {
+ AutocompleteItemElement,
+ AutocompleteItemEvents,
+} from 'prosekit/web/autocomplete'
+
+export function renderSlashMenuItem(options: {
+ label: string
+ kbd?: string
+ onSelect: (event: AutocompleteItemEvents['select']) => void
+}) {
+ const item = document.createElement(
+ 'prosekit-autocomplete-item',
+ ) as AutocompleteItemElement
+ item.className =
+ 'relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800'
+ item.addEventListener('select', (event) =>
+ options.onSelect(event as AutocompleteItemEvents['select']),
+ )
+
+ const span = document.createElement('span')
+ span.textContent = options.label
+ item.append(span)
+
+ if (options.kbd) {
+ const kbd = document.createElement('kbd')
+ kbd.className = 'text-xs font-mono text-gray-400 dark:text-gray-500'
+ kbd.textContent = options.kbd
+ item.append(kbd)
+ }
+
+ return item
+}
diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu.ts
new file mode 100644
index 000000000..7868dc566
--- /dev/null
+++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu.ts
@@ -0,0 +1,118 @@
+import 'prosekit/web/autocomplete'
+
+import type { BasicExtension } from 'prosekit/basic'
+import type { Editor } from 'prosekit/core'
+import { canUseRegexLookbehind } from 'prosekit/core'
+import type {
+ AutocompleteListElement,
+ AutocompletePopoverElement,
+} from 'prosekit/web/autocomplete'
+
+import { renderSlashMenuEmpty } from './slash-menu-empty'
+import { renderSlashMenuItem } from './slash-menu-item'
+
+// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
+const regex = canUseRegexLookbehind() ? /(?) {
+ const popover = document.createElement(
+ 'prosekit-autocomplete-popover',
+ ) as AutocompletePopoverElement
+ popover.className =
+ 'relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden'
+ popover.editor = editor
+ popover.regex = regex
+
+ const list = document.createElement(
+ 'prosekit-autocomplete-list',
+ ) as AutocompleteListElement
+ list.editor = editor
+ popover.append(list)
+
+ list.append(
+ renderSlashMenuItem({
+ label: 'Text',
+ kbd: undefined,
+ onSelect: () => editor.commands.setParagraph(),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Heading 1',
+ kbd: '#',
+ onSelect: () => editor.commands.setHeading({ level: 1 }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Heading 2',
+ kbd: '##',
+ onSelect: () => editor.commands.setHeading({ level: 2 }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Heading 3',
+ kbd: '###',
+ onSelect: () => editor.commands.setHeading({ level: 3 }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Bullet list',
+ kbd: '-',
+ onSelect: () => editor.commands.wrapInList({ kind: 'bullet' }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Ordered list',
+ kbd: '1.',
+ onSelect: () => editor.commands.wrapInList({ kind: 'ordered' }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Task list',
+ kbd: '[]',
+ onSelect: () => editor.commands.wrapInList({ kind: 'task' }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Toggle list',
+ kbd: '>>',
+ onSelect: () => editor.commands.wrapInList({ kind: 'toggle' }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Quote',
+ kbd: '>',
+ onSelect: () => editor.commands.setBlockquote(),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Table',
+ onSelect: () => editor.commands.insertTable({ row: 3, col: 3 }),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Divider',
+ kbd: '---',
+ onSelect: () => editor.commands.insertHorizontalRule(),
+ }),
+ )
+ list.append(
+ renderSlashMenuItem({
+ label: 'Code',
+ kbd: '```',
+ onSelect: () => editor.commands.setCodeBlock(),
+ }),
+ )
+ list.append(renderSlashMenuEmpty())
+
+ return popover
+}
diff --git a/vanilla-slash-menu/src/editor.ts b/vanilla-slash-menu/src/editor.ts
new file mode 100644
index 000000000..877138fee
--- /dev/null
+++ b/vanilla-slash-menu/src/editor.ts
@@ -0,0 +1,5 @@
+import { setupVanillaEditor } from './components/editor/examples/slash-menu'
+
+export function renderEditor() {
+ return setupVanillaEditor().render()
+}
diff --git a/vanilla-slash-menu/src/main.ts b/vanilla-slash-menu/src/main.ts
new file mode 100644
index 000000000..a4945ae00
--- /dev/null
+++ b/vanilla-slash-menu/src/main.ts
@@ -0,0 +1,11 @@
+import './app.css'
+import { renderEditor } from './editor'
+
+let container = document.querySelector('#app')
+if (!container) {
+ container = document.createElement('div')
+ container.id = 'app'
+ document.body.appendChild(container)
+}
+
+container.replaceChildren(renderEditor())
diff --git a/vanilla-slash-menu/tsconfig.json b/vanilla-slash-menu/tsconfig.json
new file mode 100644
index 000000000..4ba8dd95c
--- /dev/null
+++ b/vanilla-slash-menu/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/vanilla-slash-menu/vite.config.ts b/vanilla-slash-menu/vite.config.ts
new file mode 100644
index 000000000..fb0cdf00b
--- /dev/null
+++ b/vanilla-slash-menu/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [tailwindcss()],
+})