How to Build a Slash Command Quick Action Menu in Froala Editor
Posted on By Mostafa Yousef | In Tutorials,
Table of contents
- Key Takeaways
- Prerequisites
- The Complete Approach
- 1. HTML Structure
- 2. Styling the Menu
- 3. Defining the Command Data
- 4. State Management
- 5. Building the Menu DOM (
buildMenu) - 6. Positioning the Menu
- 7. Detecting the Slash Trigger (
getSlashTriggerText) - 8. Cleaning Up the Trigger Character (
selectItem) - 9. Froala Event Integration
- 10. Keyboard Navigation
- Full Flow Summary
- Extending the Menu
- Important Gotchas and Solutions
- Making It Production-Ready
- Alternative Approaches
- Conclusion
- FAQ
Notion does it. Linear does it. GitHub does it. Users now expect to type / and get an instant menu of content options. If you are integrating Froala V5 into your app, you can build the same experience from scratch using Froala’s keyup event, its html.insert() method, and a custom-rendered dropdown. No third-party library required.
In this tutorial, you’ll learn how to add a fully functional slash command menu to Froala Editor. We’ll build it from scratch, explaining every decision so you understand not just what the code does, but why.
By the end, you’ll have a clean, keyboard-friendly command menu that works reliably with Froala’s contenteditable surface.
Key Takeaways
- Froala V5 does not ship a native slash command menu, but its event and methods API gives you everything you need to build one.
- You intercept the
/keystroke using thekeyupevent, then render and position a custom dropdown relative to the cursor. - Each menu item calls
editor.html.insert()or a native Froala command to inject content at the correct position. - Proper cleanup (removing the
/trigger character, dismissing the menu onEscapeor blur) is critical for a polished UX.
Prerequisites
To follow along, you need:
- Basic knowledge of HTML, CSS, and JavaScript
- Familiarity with Froala Editor (initialization and events)
- A Froala license key (or use the trial)
We’ll use the official CDN for simplicity.
The Complete Approach
The implementation has three main responsibilities:
- Detect when the user types
/at the start of a line - Render a filtered, keyboard-navigable menu
- Execute the chosen action and clean up the trigger character
See the full, working implementation on JSFiddle. You can read the complete code with inline comments explaining every non-obvious decision, and test it live in your browser by typing / in the editor.
Let’s break down the code section by section.
1. HTML Structure
<div id="editor-container"> <div id="froala-editor"> <p>Type <strong>/</strong> at the start of a new line to open the command menu.</p> </div> </div> <div id="slash-menu"></div>
The editor lives in its own container for easy styling. The #slash-menu element is placed outside the editor so it can be positioned absolutely relative to the viewport.
2. Styling the Menu
The CSS creates a clean, Notion-style dropdown:
- Fixed width (260px) with max-height and scrolling
- Category headers in uppercase
- Icon + title + description layout
- Hover and
.is-activestates for keyboard navigation - Subtle shadows and borders for depth
These styles are intentionally decoupled from Froala’s theme so you can easily adapt them.
/* ─── Slash Command Menu Styles ─────────────────────────────── */
#slash-menu {
position: absolute;
z-index: 9999;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
width: 260px;
max-height: 320px;
overflow-y: auto;
display: none; /* Hidden by default */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
#slash-menu .slash-menu-header {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
}
#slash-menu .slash-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s ease;
}
#slash-menu .slash-menu-item:hover,
#slash-menu .slash-menu-item.is-active {
background: #f1f5f9;
}
#slash-menu .slash-menu-item .item-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
}
#slash-menu .slash-menu-item .item-text .item-title {
font-size: 13px;
font-weight: 500;
color: #1e293b;
line-height: 1.3;
}
#slash-menu .slash-menu-item .item-text .item-description {
font-size: 11px;
color: #94a3b8;
line-height: 1.3;
}
/* Editor wrapper */
#editor-container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
} 3. Defining the Command Data
const SLASH_COMMANDS = [
{
category: 'Text',
items: [
{ id: 'paragraph', title: 'Text', description: 'Plain paragraph', icon: '¶', action: ... },
...
]
},
...
]; This data structure is the heart of the feature. Each item contains:
id: Used for potential future features (like analytics)titleanddescription: Displayed in the UIicon: Simple visual indicatoraction: A function that receives the editor instance
Why separate actions from the UI? It keeps the data declarative. You can later load commands from an API or allow users to customize them without touching the rendering logic.
4. State Management
let menuVisible = false; let activeIndex = 0; let flatItems = []; let editorInstance = null;
We maintain minimal state:
menuVisiblecontrols visibility logicactiveIndextracks which item is highlighted for keyboard navigationflatItemsholds the currently visible (filtered) items with their DOM referenceseditorInstancegives us access to Froala methods inside event handlers
5. Building the Menu DOM (buildMenu)
This function is called every time the user types after a /.
function buildMenu(filter) {
menuEl.innerHTML = '';
flatItems = [];
const term = (filter || '').toLowerCase();
SLASH_COMMANDS.forEach(function(group) {
const filtered = group.items.filter(item =>
item.title.toLowerCase().includes(term) ||
item.description.toLowerCase().includes(term)
);
if (filtered.length === 0) return;
// Create category header
// Create item rows with click handlers
// Push to flatItems
});
}
Key details explained:
- We flatten the data while respecting categories
- Filtering happens on both title and description
- We use
mousedowninstead ofclickon menu items. This is critical becauseclickfires afterblur, which would close the menu before the selection logic runs.
6. Positioning the Menu
function positionMenu() {
const range = window.getSelection().getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getBoundingClientRect();
// Calculate top and left using scroll offsets
}
We use getBoundingClientRect() on a collapsed range to get the cursor’s exact position. This is more reliable than trying to measure the editor’s internal elements.
We also add a small horizontal boundary check so the menu doesn’t overflow the viewport.
7. Detecting the Slash Trigger (getSlashTriggerText)
This is the most important logic function:
function getSlashTriggerText() {
const text = node.textContent.slice(0, range.startOffset);
const slashIndex = text.lastIndexOf('/');
if (slashIndex === -1) return null;
const beforeSlash = text.slice(0, slashIndex).trim();
if (beforeSlash.length > 0) return null;
return text.slice(slashIndex + 1);
}
Why this logic matters:
- We only trigger when
/is the first non-whitespace character on the current line - We extract everything after
/as the filter term - This prevents the menu from appearing when someone types “hello/world”
8. Cleaning Up the Trigger Character (selectItem)
When the user selects a command, we must remove the / (and any filter text) before inserting the new content:
const text = node.textContent;
const slashIndex = text.lastIndexOf('/');
node.textContent = text.slice(0, slashIndex) + text.slice(range.startOffset);
We then recreate the range and place the cursor back where the / was. This is delicate work because we’re manually editing a text node inside a contenteditable area.
After cleanup, we call the item’s action function and restore focus to the editor.
9. Froala Event Integration
The real power comes from hooking into Froala’s events:
events: {
'keyup': function(e) { ... },
'keydown': function(e) { ... },
'blur': function() { ... }
}
keyup handler responsibilities:
- Ignore modifier keys
- Handle Escape to close the menu
- Manage arrow keys and Enter when the menu is visible
- Check for the slash trigger on every keystroke
keydown handler:
- Prevents default behavior for Enter and arrow keys when the menu is open
- This stops Froala from inserting a new paragraph when the user is just navigating the menu
blur handler:
- Uses a small
setTimeout(150ms) so thatmousedownevents on menu items have time to fire first
10. Keyboard Navigation
We maintain a flat list of visible items. Arrow keys simply increment/decrement activeIndex and call highlightItem().
highlightItem does two things:
- Toggles the
is-activeclass - Calls
scrollIntoView({ block: 'nearest' })so long menus scroll automatically
This creates a smooth experience similar to native dropdowns.
Full Flow Summary
- User types
/at the start of a line keyupdetects the trigger viagetSlashTriggerText()showMenu()builds filtered DOM and positions it- User can type to filter, use arrows to navigate, or click
- On selection, we remove the
/text, execute the action, hide the menu, and refocus
Extending the Menu
Adding new content types requires only adding a new object to SLASH_COMMANDS. Here is a file upload entry as an example:
{
id: 'file',
title: 'File',
description: 'Attach a file',
icon: '📎',
action: function (editor) {
// Trigger Froala's native file upload dialog
}
}
You can also add dynamic items, such as loading a list of page templates from your API and appending them into the menu on the fly inside buildMenu.
Important Gotchas and Solutions
| Problem | Solution in this code |
|---|---|
| Menu closes before click registers | Use mousedown + e.preventDefault() |
| Cursor jumps when using arrows | Prevent default in keydown |
| Filter text remains after selection | Manually edit the text node before inserting |
| Menu appears mid-sentence | Check that nothing precedes / on the line |
| Editor loses focus | Restore focus after action execution |
Making It Production-Ready
Here are several improvements you can add:
- Better table insertion: Use Froala’s
insertTablecommand instead of raw HTML when possible - Command shortcuts: Add keyboard shortcuts (e.g.,
/h1) - Icons: Replace text icons with SVG or Font Awesome for polish
- Async commands: Support commands that open modals (image upload, etc.)
- Accessibility: Add
role="listbox"andaria-activedescendant - Debouncing : The current implementation rebuilds on every key. For very large command sets, add light debouncing
Alternative Approaches
You could also implement this using:
- A separate library like Tippy.js for positioning
- MutationObserver instead of key events (more complex)
The manual approach shown here gives you maximum control and works without additional dependencies.
Conclusion
Building a slash command menu teaches you several valuable skills:
- Working with
SelectionandRangeAPIs - Managing state between a rich text editor and custom UI
- Handling the timing quirks of contenteditable elements
- Creating accessible, keyboard-first interfaces
The code is deliberately kept simple and self-contained so you can understand every line. Once you’re comfortable with the core mechanics, you can extend it with custom commands, better styling, or integration with your own design system.
Try it now: Paste the vanilla JS example into a local HTML file, add your license key, and open it in a browser. Type / at the start of a blank line to see the menu in action.
FAQ
Does Froala V5 have a built-in slash command menu?
No. Froala’s Quick Insert plugin shows a + button on empty lines and is triggered by the cursor position, not by typing /. The slash command UX in this guide is a custom layer built on top of Froala’s public API.
Will this break Froala’s built-in Quick Insert plugin?
No. The two features are independent. You can run both simultaneously if you want. The quickInsertEnabled option controls the + button behavior and is unrelated to the keyup event handler you are adding.
Can I add custom icons using SVG instead of emoji?
Yes. Replace the icon string in any SLASH_COMMANDS item with an SVG string, and update the .item-icon container’s innerHTML assignment in buildMenu accordingly.
How do I filter commands server-side or asynchronously? Modify buildMenu to be async and replace the SLASH_COMMANDS.forEach block with an await fetch(...) call. Pass the filter string as a query parameter. Show a loading spinner in the menu while the request is in flight.
How do I handle the slash command in Angular?
The pattern is identical. Inject the Froala config object with the same events block into your @Input() editorConfig. Access the editor instance from the initialized callback and store it in a component property. All other logic (DOM manipulation for the dropdown, text node traversal) lives in separate service methods.
What happens if the user types “/” and then presses Backspace?
The keyup handler fires after the Backspace. getSlashTriggerText re-evaluates the current text node. If the “/” has been deleted, slashIndex will be -1 and the function returns null, which triggers hideMenu(). The menu disappears automatically.
- Whats on this page hide
No comment yet, add your voice below!