How to keep the contenteditable selection when clicking a toolbar button
Published 2026-10-19 · Applies to every browser and to any editor built on contenteditable, including plain document.execCommand setups.
The symptom
You select a word in a contenteditable element and click a Bold button. Nothing happens, or the formatting lands at the wrong place. If you log document.getSelection().toString() inside the click handler, it is empty.
<div id="toolbar">
<button id="bold">Bold</button>
</div>
<div id="editor" contenteditable="true">Select some text, then click Bold.</div>
<script>
// ❌ By the time "click" fires, the mousedown has already moved focus
// to the button and the selection inside #editor is gone.
document.querySelector('#bold').addEventListener('click', () => {
document.execCommand('bold'); // nothing happens
});
</script>Why the selection is lost
A mouse press fires events in this order: pointerdown, mousedown, focus on the button, pointerup, mouseup, and finally click. The default action of mousedown is to focus the element under the pointer. As soon as focus moves from the editor to the button, the browser collapses or moves the document selection away from the editable content.
Your click handler runs last, after the damage is done. That is why saving the selection inside click never works: there is nothing left to save.
Fix 1: cancel mousedown on the toolbar
Calling preventDefault() on mousedown cancels the focus change. The editor keeps focus, the selection stays where it was, and the click event still fires normally. Attach one listener to the toolbar container instead of one per button.
const toolbar = document.querySelector('#toolbar');
// ✅ Cancel the default action of mousedown on the whole toolbar.
// The button never takes focus, so the editor keeps its selection.
toolbar.addEventListener('mousedown', (event) => {
// Let real form fields (link URL input, <select>) take focus normally.
if (event.target.closest('input, select, textarea')) return;
event.preventDefault();
});
// The click handler now runs while the selection is still intact.
toolbar.querySelector('#bold').addEventListener('click', () => {
document.execCommand('bold');
});The early return for input, select and textarea matters: without it, form fields inside the toolbar can never be focused or typed into.
The same fix in React, Vue and Svelte
Frameworks do not change the browser behaviour, so the fix is identical: cancel mousedown on the wrapper and keep your logic in click.
export function Toolbar({ editor }) {
return (
// One handler on the wrapper covers every button inside it
<div role="toolbar" aria-label="Formatting" onMouseDown={(e) => e.preventDefault()}>
<button type="button" onClick={() => editor.bold()}>Bold</button>
<button type="button" onClick={() => editor.italic()}>Italic</button>
</div>
);
}<template>
<!-- .prevent calls event.preventDefault() for you -->
<div role="toolbar" aria-label="Formatting" @mousedown.prevent>
<button type="button" @click="editor.bold()">Bold</button>
<button type="button" @click="editor.italic()">Italic</button>
</div>
</template><!-- Svelte 5 -->
<div role="toolbar" aria-label="Formatting" onmousedown={(e) => e.preventDefault()}>
<button type="button" onclick={() => editor.bold()}>Bold</button>
</div>
<!-- Svelte 4 -->
<div role="toolbar" aria-label="Formatting" on:mousedown|preventDefault>
<button type="button" on:click={() => editor.bold()}>Bold</button>
</div>Framework-specific setup for a full editor is covered in the React, Vue and Svelte rich text editor guides.
Fix 2: save and restore the Range when focus must move
Some controls have to take focus: a link URL field, a font-size input, a color picker, or a modal dialog. You cannot cancel that focus change, so store the selection before it happens and put it back before you run the command.
The most robust place to save is selectionchange, filtered to ranges inside the editor. That keeps the last good selection no matter how the user leaves the editor: mouse, Tab key, or touch.
const editor = document.querySelector('#editor');
let savedRange = null;
// Remember the last selection that was inside the editor.
document.addEventListener('selectionchange', () => {
const sel = document.getSelection();
if (sel && sel.rangeCount > 0 && editor.contains(sel.getRangeAt(0).commonAncestorContainer)) {
savedRange = sel.getRangeAt(0).cloneRange();
}
});
function restoreSelection() {
if (!savedRange) return false;
editor.focus({ preventScroll: true });
const sel = document.getSelection();
sel.removeAllRanges();
sel.addRange(savedRange);
return true;
}
// Link button: the URL input MUST take focus, so the selection is lost here.
linkInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
if (restoreSelection()) {
document.execCommand('createLink', false, linkInput.value);
}
});Always call cloneRange() when saving. The live Range returned by getRangeAt(0) can change as the selection changes, and a clone keeps the exact boundaries you want to restore. Focus the editor with preventScroll: true before addRange() so the page does not jump.
Editors inside an iframe
When the editable body lives in an iframe and the toolbar in the parent page, a press on the toolbar moves focus out of the iframe entirely. The same mousedown fix applies, but read and change the selection through the iframe's own contentWindow and contentDocument.
const frame = document.querySelector('iframe#editor-frame');
const frameDoc = frame.contentDocument;
// The toolbar lives in the parent page, the editable body in the iframe.
toolbar.addEventListener('mousedown', (e) => e.preventDefault());
toolbar.querySelector('#bold').addEventListener('click', () => {
// Use the iframe's own document and selection, not the parent's.
frameDoc.execCommand('bold');
const sel = frame.contentWindow.getSelection();
console.log('still selected:', sel.toString());
});Touch devices and keyboard users
On touch screens the browser fires compatibility mouse events after the touch, and mobile browsers differ in how they handle focus and selection handles. Keep the mousedown fix, but also keep the saved-Range fallback from Fix 2 and test on real iOS Safari and Android Chrome.
Cancelling mousedown does not affect the keyboard. Users can still Tab to the buttons and press Enter or Space; in that case focus does move, which is another reason to restore the saved Range before applying a command. Use real <button type="button"> elements inside a container with role="toolbar" and an aria-label.
Common mistakes
Saving the selection inside the click handler
click fires after mousedown and mouseup. The focus change happened on mousedown, so the range you read in click is already collapsed or outside the editor.
Replacing <button> with a <div> or <span>
Pressing on a non-focusable element still blurs the editor and can start a new text selection. It also removes keyboard access. Keep real buttons and cancel mousedown.
Using only user-select: none on the toolbar
It stops the toolbar label text from being selected, but it does not stop the focus change that clears the editor selection.
Calling preventDefault on mousedown for every element on the page
Inputs, selects and textareas inside the toolbar then never receive focus. Scope the handler to the toolbar and skip form fields.
Relying on pointerdown alone
Cancelling pointerdown suppresses later compatibility mouse events, but it is not a reliable way to stop the focus change in every browser. mousedown is the event that works everywhere.
Checklist
- One mousedown listener on the toolbar container calls preventDefault()
- Form fields inside the toolbar are skipped so they can still take focus
- Formatting logic runs in click, not in mousedown
- The last in-editor Range is saved with cloneRange() on selectionchange
- Dialogs and inputs restore that Range before applying the command
- Buttons are real <button type="button"> elements inside role="toolbar"
- Tested with mouse, keyboard and a touch device
How Scribe Editor handles it
Scribe's floating and fixed toolbars cancel mousedown on the toolbar wrapper and keep selection as first-class editor state, so formatting buttons work without any extra code. For custom UI that needs focus, the editor exposes saveSelection() and restoreSelection(), which do the Range bookkeeping from Fix 2 for you. The core concepts page explains the selection model, and the API reference lists every method.
import { Scribe } from 'scribejs-editor';
// The floating and fixed toolbars already cancel mousedown and keep
// the selection, so formatting buttons work out of the box.
const editor = Scribe.init('#editor', { toolbar: 'floating' });
// For your own UI that has to take focus (a link dialog, a color picker),
// use the built-in snapshot instead of writing a selectionchange tracker:
openLinkDialogButton.addEventListener('click', () => {
editor.saveSelection();
showLinkDialog({
onSubmit(url) {
editor.restoreSelection();
editor.link(url);
},
});
});Skip the selection bugs
Scribe Editor is an MIT-licensed rich text editor under 50KB with toolbars that keep the selection, built-in XSS sanitization, and a direct API for React, Vue, Svelte and Vanilla JS.
Selection and toolbar buttons: FAQ
Why does clicking a toolbar button lose the selection in a contenteditable element?
The default action of mousedown on a focusable element is to move focus to it. When focus leaves the contenteditable element, the browser collapses or moves the document selection, so by the time your click handler runs there is no selection left to format.
How do I keep the selection when clicking a button?
Call event.preventDefault() in a mousedown listener on the toolbar (or on the button). This cancels the focus change, so the editor keeps focus and its selection, and your click handler can apply formatting with the selection still intact.
Does preventDefault on mousedown break the click event or accessibility?
No. The click event still fires, and keyboard users can still Tab to the buttons and press Enter or Space. Keep the buttons as real <button type="button"> elements inside a container with role="toolbar" so screen readers announce them correctly.
What if the toolbar needs an input, like a link URL field?
An input must take focus, so the selection will be lost. Save a clone of the current Range before the input gets focus (for example on selectionchange), then focus the editor and restore that Range with removeAllRanges() and addRange() before applying the command.
Is document.execCommand still safe to use?
execCommand is marked deprecated but is still supported by all major browsers and is used in the examples here because it is the shortest way to show the selection problem. Editors such as Scribe apply formatting through their own API (editor.bold(), editor.link(url)) instead.