Skip to main content

How to Work with Selection and Range in JavaScript

Text selection is something users do constantly: highlighting text to copy, selecting words to look up, or editing content in rich text editors. Behind every selection the user makes, the browser maintains precise data structures that describe exactly which parts of the DOM are selected. JavaScript gives you full access to these structures through the Range and Selection APIs, allowing you to read what the user has selected, create selections programmatically, manipulate selected content, and build features like custom text editors, annotation tools, and syntax highlighters.

This guide covers the Range object for defining arbitrary portions of the DOM, the Selection object that represents what the user sees highlighted, selection-related events, how to create and modify selections with JavaScript, and how contenteditable turns any element into an editable area.

The Range Object: Selecting Parts of the DOM

A Range represents a fragment of the document. It defines a start point and an end point within the DOM tree, and everything between those two points is "inside" the range. A range can span text within a single node, cover multiple elements, or even cut across element boundaries.

Range Boundaries

Every range has two boundaries:

  • Start boundary: a node and an offset within that node
  • End boundary: a node and an offset within that node

For text nodes, the offset is a character position (0 means before the first character, 1 means after the first character, and so on). For element nodes, the offset is a child node index (0 means before the first child, 1 means after the first child, etc.).

<p id="paragraph">Hello, <strong>beautiful</strong> world!</p>

The DOM structure of this paragraph looks like:

<p>
├── "Hello, " (text node)
├── <strong>
│ └── "beautiful" (text node)
└── " world!" (text node)

A range that selects the word "beautiful" would have:

  • Start: the text node inside <strong>, offset 0
  • End: the text node inside <strong>, offset 9

A range that selects "lo, beautiful wo" would span across multiple nodes:

  • Start: the first text node "Hello, ", offset 3
  • End: the last text node " world!", offset 3

Creating a Range

Create a range with document.createRange() or new Range():

const range = new Range();
// or
const range = document.createRange();

A newly created range starts and ends at the very beginning of the document (both boundaries at offset 0 in the document node). You set the boundaries explicitly.

Setting Range Boundaries

The primary methods for setting boundaries are:

const range = new Range();
const p = document.getElementById('paragraph');

// Set start: node, offset
range.setStart(node, offset);

// Set end: node, offset
range.setEnd(node, offset);

Here is a practical example selecting a portion of text:

<p id="text">The quick brown fox jumps over the lazy dog.</p>

<script>
const textNode = document.getElementById('text').firstChild;

const range = new Range();
range.setStart(textNode, 4); // After "The "
range.setEnd(textNode, 19); // After "quick brown fox"

console.log(range.toString()); // "quick brown fox"
</script>

When working with element nodes, the offset refers to child positions:

<ul id="list">
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>

<script>
const list = document.getElementById('list');

const range = new Range();
range.setStart(list, 0); // Before first <li>
range.setEnd(list, 2); // After second <li> (before third)

console.log(range.toString()); // "Apple\nBanana"
</script>

Convenience Methods for Setting Boundaries

Rather than calculating offsets manually, Range provides shortcut methods:

// Select an entire node and its contents
range.selectNode(node);
// Start is before the node, end is after the node (relative to parent)

// Select only the contents inside a node
range.selectNodeContents(node);
// Start is at the beginning of the node's content, end is at the end

The difference between these two is important:

<p>Hello, <strong id="bold">world</strong>!</p>

<script>
const strong = document.getElementById('bold');

const range1 = new Range();
range1.selectNode(strong);
console.log(range1.toString()); // "world"
// range1 includes the <strong> element itself

const range2 = new Range();
range2.selectNodeContents(strong);
console.log(range2.toString()); // "world"
// range2 includes only the contents inside <strong>
</script>

While both produce the same text output in this case, they differ structurally. selectNode wraps around the element (start and end are in the parent), while selectNodeContents is inside the element (start and end are within the element itself). This matters when extracting or manipulating the range content.

Additional boundary methods:

// Set start/end before or after a specific node
range.setStartBefore(node); // Start just before this node
range.setStartAfter(node); // Start just after this node
range.setEndBefore(node); // End just before this node
range.setEndAfter(node); // End just after this node
<p>A <em id="em">B</em> C</p>

<script>
const em = document.getElementById('em');

const range = new Range();
range.setStartBefore(em);
range.setEndAfter(em);
console.log(range.toString()); // "B"
// This is equivalent to range.selectNode(em)
</script>

Collapsed Ranges (Cursor Position)

A range where the start and end are at the same point is called collapsed. A collapsed range represents a cursor position (also called a caret) rather than a selection of content:

const range = new Range();
const textNode = document.getElementById('text').firstChild;

range.setStart(textNode, 5);
range.setEnd(textNode, 5);

console.log(range.collapsed); // true
// This represents the cursor position after "The q"

You can collapse an existing range to either its start or end:

range.collapse(true);  // Collapse to start
range.collapse(false); // Collapse to end (default)

Creating and Modifying Ranges

Once you have a range, you can extract, clone, delete, or replace its contents. These methods make Range a powerful tool for DOM manipulation.

Reading Range Properties

const range = new Range();
const textNode = document.getElementById('text').firstChild;
range.setStart(textNode, 4);
range.setEnd(textNode, 19);

console.log(range.startContainer); // The text node
console.log(range.startOffset); // 4
console.log(range.endContainer); // The text node
console.log(range.endOffset); // 19
console.log(range.collapsed); // false
console.log(range.commonAncestorContainer); // Nearest common ancestor of start and end
console.log(range.toString()); // "quick brown fox" (text content)

cloneContents(): Copy Without Removing

cloneContents() returns a DocumentFragment containing a deep copy of everything inside the range. The original DOM is untouched:

<p id="source">The <strong>quick brown</strong> fox</p>
<div id="destination"></div>

<script>
const p = document.getElementById('source');
const range = new Range();
range.selectNodeContents(p);

const fragment = range.cloneContents();
document.getElementById('destination').appendChild(fragment);

// Both source and destination now show the same content
// Source is unchanged
</script>

extractContents(): Cut (Remove and Return)

extractContents() removes the content from the DOM and returns it as a DocumentFragment:

<p id="text">Hello, beautiful world!</p>
<div id="extracted"></div>

<script>
const textNode = document.getElementById('text').firstChild;

const range = new Range();
range.setStart(textNode, 7); // After "Hello, "
range.setEnd(textNode, 16); // After "beautiful"

const fragment = range.extractContents();
document.getElementById('extracted').appendChild(fragment);

// <p> now contains: "Hello, world!"
// <div> now contains: "beautiful"
</script>

deleteContents(): Remove Without Returning

deleteContents() removes the content from the DOM without returning it:

const range = new Range();
range.selectNodeContents(document.getElementById('to-remove'));
range.deleteContents();
// Content is gone, nothing returned

insertNode(): Insert at Range Start

insertNode() inserts a node at the beginning of the range:

<p id="text">Hello world!</p>

<script>
const textNode = document.getElementById('text').firstChild;

const range = new Range();
range.setStart(textNode, 5);
range.setEnd(textNode, 5); // Collapsed at position 5

const span = document.createElement('span');
span.style.color = 'red';
span.textContent = ', beautiful';

range.insertNode(span);
// <p> now contains: "Hello<span style="color:red">, beautiful</span> world!"
</script>

surroundContents(): Wrap Range in an Element

surroundContents() wraps the range content inside a new element. This only works if the range does not partially select any non-text nodes:

<p id="text">The quick brown fox</p>

<script>
const textNode = document.getElementById('text').firstChild;

const range = new Range();
range.setStart(textNode, 4);
range.setEnd(textNode, 15);

const highlight = document.createElement('mark');
range.surroundContents(highlight);

// <p> now contains: "The <mark>quick brown</mark> fox"
</script>
warning

surroundContents() throws an error if the range partially selects an element (starts inside one element and ends inside another). In such cases, use extractContents() followed by appendChild() and insertNode() instead:

// ❌ This throws if range crosses element boundaries
range.surroundContents(wrapper);

// ✅ This works for any range
const wrapper = document.createElement('mark');
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);

cloneRange(): Duplicate a Range

Create an independent copy of a range:

const original = new Range();
original.selectNodeContents(element);

const copy = original.cloneRange();
// Modifying copy does not affect original

compareBoundaryPoints(): Comparing Ranges

You can compare the positions of two ranges:

const range1 = new Range();
const range2 = new Range();
// ... set boundaries ...

// Compare start of range1 with start of range2
const result = range1.compareBoundaryPoints(Range.START_TO_START, range2);
// Returns: -1 (range1 starts before), 0 (same position), 1 (range1 starts after)

The comparison constants are:

  • Range.START_TO_START: compare starts
  • Range.START_TO_END: compare start of first with end of second
  • Range.END_TO_END: compare ends
  • Range.END_TO_START: compare end of first with start of second

Getting Coordinates with getBoundingClientRect()

Ranges have geometry methods that return their position on screen, which is useful for positioning tooltips, toolbars, or annotations:

const range = window.getSelection().getRangeAt(0);

const rect = range.getBoundingClientRect();
console.log(`Top: ${rect.top}, Left: ${rect.left}`);
console.log(`Width: ${rect.width}, Height: ${rect.height}`);

// For multi-line selections, get all rects
const rects = range.getClientRects();
for (const r of rects) {
console.log(`Line rect: ${r.top}, ${r.left}, ${r.width}x${r.height}`);
}

This is how text editor toolbars are positioned above selected text:

function showToolbar(selection) {
if (selection.isCollapsed) {
hideToolbar();
return;
}

const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();

toolbar.style.position = 'fixed';
toolbar.style.left = `${rect.left + rect.width / 2 - toolbar.offsetWidth / 2}px`;
toolbar.style.top = `${rect.top - toolbar.offsetHeight - 8}px`;
toolbar.style.display = 'block';
}

The Selection Object: User's Text Selection

While Range is a low-level API for defining DOM fragments, the Selection object represents what the user actually sees highlighted in the browser. The user creates a selection by clicking and dragging the mouse, double-clicking a word, or using keyboard shortcuts like Shift+Arrow keys.

Getting the Current Selection

const selection = window.getSelection();
// or
const selection = document.getSelection(); // Same thing

The Selection object has several useful properties:

const selection = window.getSelection();

console.log(selection.toString()); // The selected text as a string
console.log(selection.rangeCount); // Number of ranges (usually 1)
console.log(selection.isCollapsed); // true if nothing is selected (just a cursor)
console.log(selection.anchorNode); // Node where the selection started
console.log(selection.anchorOffset); // Offset in the anchor node
console.log(selection.focusNode); // Node where the selection ended
console.log(selection.focusOffset); // Offset in the focus node

Anchor vs. Focus

The selection has an anchor (where the user started selecting) and a focus (where the user ended). These can be in either order depending on the direction the user dragged:

  • Forward selection (left to right): anchor is before focus
  • Backward selection (right to left): anchor is after focus
const sel = window.getSelection();

// Forward selection: "Hello" selected left-to-right
// anchorOffset = 0, focusOffset = 5

// Backward selection: "Hello" selected right-to-left
// anchorOffset = 5, focusOffset = 0

The Range object, by contrast, always has its start before its end. When you get a range from a backward selection, the start and end are normalized.

Getting the Range from a Selection

Most of the time, you work with the Range extracted from the selection:

const selection = window.getSelection();

if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0); // Get the first (usually only) range
console.log('Selected text:', range.toString());
console.log('Start:', range.startContainer, range.startOffset);
console.log('End:', range.endContainer, range.endOffset);
}
note

In most browsers, the user can only have one selection range at a time. Firefox is the exception: it supports multiple ranges (using Ctrl+click to select multiple non-contiguous text segments). Always check rangeCount and handle the possibility of multiple ranges if cross-browser robustness is important.

Practical Example: Getting Selected Text

document.addEventListener('mouseup', () => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();

if (selectedText.length > 0) {
console.log(`User selected: "${selectedText}"`);
}
});

Reading Selection in Form Elements

For <input> and <textarea> elements, the Selection API does not apply. These elements have their own selection properties:

const input = document.getElementById('my-input');

// Reading selection
console.log(input.selectionStart); // Start index
console.log(input.selectionEnd); // End index
console.log(input.selectionDirection); // "forward", "backward", or "none"

// Getting the selected text
const selectedText = input.value.substring(input.selectionStart, input.selectionEnd);
console.log('Selected in input:', selectedText);

Selection Events: selectionchange and selectstart

selectionchange: Any Change to the Selection

The selectionchange event fires on document whenever the selection changes. This includes selecting text, deselecting text, moving the cursor (collapsed selection), and typing in an editable area.

document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
const text = selection.toString();

if (text) {
console.log('Current selection:', text);
} else {
console.log('Selection cleared or cursor moved');
}
});

This event fires frequently, especially during text editing, so keep the handler lightweight:

// Debounced selection handler for a floating toolbar
let debounceTimer;

document.addEventListener('selectionchange', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const selection = window.getSelection();

if (!selection.isCollapsed && selection.toString().trim()) {
showFloatingToolbar(selection);
} else {
hideFloatingToolbar();
}
}, 200);
});
note

The selectionchange event fires on document, not on individual elements. If you need to track selection changes within a specific element, check whether the selection is inside that element in your handler:

document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;

const range = selection.getRangeAt(0);
const editor = document.getElementById('editor');

if (editor.contains(range.commonAncestorContainer)) {
// Selection is inside the editor
updateToolbarState(selection);
}
});

selectstart: Before Selection Begins

The selectstart event fires on the element where a new selection is about to begin. Unlike selectionchange, this event is cancelable. Calling preventDefault() prevents the selection from starting:

// Prevent text selection on a specific element
const nonSelectableArea = document.getElementById('no-select');

nonSelectableArea.addEventListener('selectstart', (event) => {
event.preventDefault();
console.log('Selection prevented');
});

This is often paired with the CSS property user-select: none, which achieves the same effect without JavaScript:

.no-select {
user-select: none;
}

However, the JavaScript approach gives you conditional control:

element.addEventListener('selectstart', (event) => {
// Only prevent selection while dragging
if (isDragging) {
event.preventDefault();
}
// Allow selection when not dragging
});

For Input and Textarea Elements

Input and textarea elements fire select (not selectionchange) when the user selects text within them:

const textarea = document.getElementById('my-textarea');

textarea.addEventListener('select', () => {
const selected = textarea.value.substring(
textarea.selectionStart,
textarea.selectionEnd
);
console.log('Selected in textarea:', selected);
});

Programmatic Selection and Deselection

JavaScript can create, modify, and remove selections, making it possible to build features like "select all," "select this word," or highlighting specific content for the user.

Creating a Selection Programmatically

To select content, create a Range, then add it to the Selection:

<p id="important">This text will be selected programmatically.</p>
<button id="select-btn">Select Text</button>

<script>
document.getElementById('select-btn').addEventListener('click', () => {
const paragraph = document.getElementById('important');

const range = new Range();
range.selectNodeContents(paragraph);

const selection = window.getSelection();
selection.removeAllRanges(); // Clear any existing selection
selection.addRange(range); // Apply the new range
});
</script>

Selecting Specific Text

Select a specific word or substring:

<p id="text">The quick brown fox jumps over the lazy dog.</p>
<button onclick="selectWord('brown')">Select "brown"</button>

<script>
function selectWord(word) {
const textNode = document.getElementById('text').firstChild;
const text = textNode.textContent;
const start = text.indexOf(word);

if (start === -1) return;

const range = new Range();
range.setStart(textNode, start);
range.setEnd(textNode, start + word.length);

const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
</script>

Selection Methods

The Selection object provides methods for modifying the selection:

const selection = window.getSelection();

// Remove all ranges (clear the selection)
selection.removeAllRanges();

// Add a range to the selection
selection.addRange(range);

// Collapse the selection to a point (create cursor)
selection.collapse(node, offset);

// Collapse to the start or end of the current selection
selection.collapseToStart();
selection.collapseToEnd();

// Extend the selection to a new point
selection.extend(node, offset);

// Select all content inside a node
selection.selectAllChildren(node);

// Remove the selection from the given range
selection.removeRange(range);

Select All Content in an Element

function selectAll(element) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.selectAllChildren(element);
}

// Usage: select all content in a code block for easy copying
document.querySelectorAll('.code-block').forEach(block => {
block.addEventListener('click', () => {
selectAll(block);
});
});

Deselecting

// Method 1: Remove all ranges
window.getSelection().removeAllRanges();

// Method 2: Collapse (keep cursor at the end of previous selection)
window.getSelection().collapseToEnd();

// Method 3: Using the empty() method (WebKit alias for removeAllRanges)
window.getSelection().empty?.();

Setting Cursor Position in an Input

For input and textarea elements, use setSelectionRange():

const input = document.getElementById('my-input');

// Place cursor at position 5
input.setSelectionRange(5, 5);
input.focus(); // Must focus for cursor to appear

// Select characters 3 through 8
input.setSelectionRange(3, 8);
input.focus();

// Select all text
input.select();

Building a "Copy Code" Button

A practical example combining selection, range, and clipboard:

<div class="code-container">
<pre><code id="code-sample">const greeting = "Hello, world!";
console.log(greeting);</code></pre>
<button class="copy-btn" id="copy-code">Copy</button>
</div>

<script>
document.getElementById('copy-code').addEventListener('click', async () => {
const codeElement = document.getElementById('code-sample');
const text = codeElement.textContent;
const button = document.getElementById('copy-code');

try {
// Modern approach: Clipboard API
await navigator.clipboard.writeText(text);
button.textContent = 'Copied!';
} catch (err) {
// Fallback: programmatic selection + execCommand
const range = new Range();
range.selectNodeContents(codeElement);

const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);

try {
document.execCommand('copy');
button.textContent = 'Copied!';
} catch (e) {
button.textContent = 'Failed';
}

selection.removeAllRanges();
}

setTimeout(() => {
button.textContent = 'Copy';
}, 2000);
});
</script>

Highlighting Search Results

A practical application of Range manipulation is highlighting search results within a document:

function highlightText(container, searchTerm) {
// First, remove any existing highlights
removeHighlights(container);

if (!searchTerm) return;

const treeWalker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
);

const textNodes = [];
while (treeWalker.nextNode()) {
textNodes.push(treeWalker.currentNode);
}

for (const textNode of textNodes) {
const text = textNode.textContent;
const index = text.toLowerCase().indexOf(searchTerm.toLowerCase());

if (index === -1) continue;

const range = new Range();
range.setStart(textNode, index);
range.setEnd(textNode, index + searchTerm.length);

const mark = document.createElement('mark');
mark.className = 'search-highlight';

// Wrap the matched text in <mark>
range.surroundContents(mark);
}
}

function removeHighlights(container) {
container.querySelectorAll('mark.search-highlight').forEach(mark => {
const parent = mark.parentNode;
parent.replaceChild(document.createTextNode(mark.textContent), mark);
parent.normalize(); // Merge adjacent text nodes
});
}

// Usage
const searchInput = document.getElementById('search');
const content = document.getElementById('article-content');

searchInput.addEventListener('input', () => {
highlightText(content, searchInput.value);
});
tip

After removing highlight <mark> elements and replacing them with text nodes, call parentNode.normalize() to merge adjacent text nodes back together. Without this, the DOM accumulates fragmented text nodes that can break subsequent Range operations and text searches.

Making Content Editable: contenteditable

The contenteditable attribute turns any HTML element into an editable area where users can type, delete, format, and paste content. Unlike <input> and <textarea>, which handle only plain text, contenteditable elements work with rich HTML content.

Basic Usage

<div id="editor" contenteditable="true"
style="border: 1px solid #ccc; padding: 16px; min-height: 100px; border-radius: 8px;">
<p>Click here and start typing...</p>
</div>

The contenteditable attribute accepts three values:

  • "true": the element is editable
  • "false": the element is not editable (even if a parent is)
  • "inherit": inherits from the parent (default)
<div contenteditable="true">
<p>This paragraph is editable.</p>
<p contenteditable="false">This paragraph is NOT editable.</p>
<p>This paragraph is editable again.</p>
</div>

Listening for Content Changes

Editable elements fire the input event on every change, just like form fields:

const editor = document.getElementById('editor');

editor.addEventListener('input', () => {
console.log('Content changed');
console.log('HTML:', editor.innerHTML);
console.log('Text:', editor.textContent);
});

Working with Selection in Contenteditable

Selection and Range are essential for building features inside editable areas. The cursor position in a contenteditable element is a collapsed Selection:

function getCursorPosition(editableElement) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;

const range = selection.getRangeAt(0);
if (!editableElement.contains(range.commonAncestorContainer)) return null;

// Create a range from the start of the editable to the cursor
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(editableElement);
preCaretRange.setEnd(range.startContainer, range.startOffset);

return preCaretRange.toString().length;
}

function setCursorPosition(editableElement, position) {
const treeWalker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);

let currentPos = 0;
while (treeWalker.nextNode()) {
const textNode = treeWalker.currentNode;
const nodeLength = textNode.textContent.length;

if (currentPos + nodeLength >= position) {
const range = new Range();
range.setStart(textNode, position - currentPos);
range.collapse(true);

const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return;
}

currentPos += nodeLength;
}
}

Building a Simple Rich Text Editor

Here is a practical example combining contenteditable, selection, and range manipulation to build a basic rich text editor:

<style>
.toolbar {
display: flex;
gap: 4px;
padding: 8px;
background: #f5f5f5;
border: 1px solid #ccc;
border-bottom: none;
border-radius: 8px 8px 0 0;
}
.toolbar button {
padding: 6px 12px;
border: 1px solid #ccc;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.toolbar button:hover { background: #e8e8e8; }
.toolbar button.active { background: #d0d0ff; border-color: #99f; }

.editor-area {
border: 1px solid #ccc;
padding: 16px;
min-height: 200px;
border-radius: 0 0 8px 8px;
outline: none;
line-height: 1.6;
}
</style>

<div class="toolbar" id="toolbar">
<button data-command="bold" title="Bold"><b>B</b></button>
<button data-command="italic" title="Italic"><i>I</i></button>
<button data-command="underline" title="Underline"><u>U</u></button>
<button data-command="strikeThrough" title="Strikethrough"><s>S</s></button>
<button data-command="insertUnorderedList" title="Bullet List">• List</button>
<button data-command="insertOrderedList" title="Numbered List">1. List</button>
<button id="link-btn" title="Insert Link">🔗 Link</button>
</div>

<div class="editor-area" id="editor" contenteditable="true">
<p>Start writing your content here...</p>
</div>

<script>
const editor = document.getElementById('editor');
const toolbar = document.getElementById('toolbar');

// Handle formatting buttons
toolbar.addEventListener('click', (event) => {
const button = event.target.closest('[data-command]');
if (!button) return;

event.preventDefault();
const command = button.dataset.command;

document.execCommand(command, false, null);
editor.focus();
updateToolbarState();
});

// Handle link insertion
document.getElementById('link-btn').addEventListener('click', (event) => {
event.preventDefault();

const selection = window.getSelection();
if (selection.isCollapsed) {
alert('Please select some text first');
return;
}

const url = prompt('Enter URL:', 'https://');
if (url) {
document.execCommand('createLink', false, url);
}
editor.focus();
});

// Update toolbar button states based on current selection
function updateToolbarState() {
toolbar.querySelectorAll('[data-command]').forEach(button => {
const command = button.dataset.command;
const isActive = document.queryCommandState(command);
button.classList.toggle('active', isActive);
});
}

// Update toolbar when selection changes
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;

const range = selection.getRangeAt(0);
if (editor.contains(range.commonAncestorContainer)) {
updateToolbarState();
}
});

// Keyboard shortcuts
editor.addEventListener('keydown', (event) => {
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'b':
event.preventDefault();
document.execCommand('bold');
updateToolbarState();
break;
case 'i':
event.preventDefault();
document.execCommand('italic');
updateToolbarState();
break;
case 'u':
event.preventDefault();
document.execCommand('underline');
updateToolbarState();
break;
}
}
});
</script>
warning

document.execCommand() is officially deprecated but still widely supported and used. There is no complete replacement API yet. Modern rich text editors typically use the Selection and Range APIs directly for formatting, or rely on frameworks like ProseMirror, TipTap, or Slate that abstract away the complexities. For simple formatting needs, execCommand still works reliably across browsers.

Inserting HTML at the Cursor

Instead of execCommand, you can use Range methods to insert content at the current cursor position:

function insertAtCursor(html) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;

const range = selection.getRangeAt(0);
range.deleteContents(); // Remove any selected content

// Parse the HTML string into nodes
const template = document.createElement('template');
template.innerHTML = html;
const fragment = template.content;

// Get the last inserted node for cursor positioning
const lastNode = fragment.lastChild;

range.insertNode(fragment);

// Move cursor after the inserted content
if (lastNode) {
const newRange = new Range();
newRange.setStartAfter(lastNode);
newRange.collapse(true);

selection.removeAllRanges();
selection.addRange(newRange);
}
}

// Usage: insert an emoji at the cursor
document.getElementById('emoji-btn').addEventListener('click', () => {
insertAtCursor('<span class="emoji">😀</span>');
editor.focus();
});

Saving and Restoring Selection

When the user clicks a toolbar button, the editor loses focus and the selection disappears. You need to save and restore the selection:

let savedRange = null;

function saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
savedRange = selection.getRangeAt(0).cloneRange();
}
}

function restoreSelection() {
if (savedRange) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedRange);
}
}

// Save before clicking toolbar
editor.addEventListener('blur', saveSelection);

// Restore when applying formatting
function applyFormat(tag) {
restoreSelection();
editor.focus();

// Now the selection is back, apply formatting...
const selection = window.getSelection();
if (selection.isCollapsed) return;

const range = selection.getRangeAt(0);
const wrapper = document.createElement(tag);
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}

Paste Handling in Contenteditable

By default, pasting into a contenteditable element preserves formatting from the source (bold, links, styles from other websites). You often want to strip this formatting and paste only plain text:

editor.addEventListener('paste', (event) => {
event.preventDefault();

// Get plain text only
const text = event.clipboardData.getData('text/plain');

// Insert as plain text
const selection = window.getSelection();
if (selection.rangeCount === 0) return;

const range = selection.getRangeAt(0);
range.deleteContents();

// Split text into paragraphs
const lines = text.split('\n');
const fragment = document.createDocumentFragment();

lines.forEach((line, index) => {
if (index > 0) {
fragment.appendChild(document.createElement('br'));
}
fragment.appendChild(document.createTextNode(line));
});

range.insertNode(fragment);

// Move cursor to end of pasted content
selection.collapseToEnd();
});

contenteditable vs. <textarea>

Featurecontenteditable<textarea>
Content typeRich HTMLPlain text only
FormattingBold, italic, links, images, etc.None
Value accesselement.innerHTML / textContentelement.value
Selection APIwindow.getSelection() / RangeselectionStart / selectionEnd
Form submissionNot submitted automaticallySubmitted with form
StylingFull CSS control over contentLimited
ComplexityHigh (many edge cases)Low
Use caseRich text editors, annotationsComments, code input, notes

Summary

The Selection and Range APIs give you precise control over text selection and DOM fragment manipulation in JavaScript:

  • A Range defines a start and end point within the DOM tree. Set boundaries with setStart()/setEnd(), or use convenience methods like selectNodeContents(), setStartBefore(), and setEndAfter(). A collapsed range represents a cursor position.
  • Ranges can manipulate DOM content: cloneContents() copies without removing, extractContents() cuts, deleteContents() removes, insertNode() adds content at the range start, and surroundContents() wraps the range in an element.
  • The Selection object (window.getSelection()) represents what the user sees highlighted. It has an anchor (where selection started) and a focus (where it ended). Extract the underlying Range with getRangeAt(0).
  • selectionchange fires on document whenever the selection changes. selectstart fires when a new selection begins and can be prevented.
  • Create selections programmatically with selection.removeAllRanges() followed by selection.addRange(range). For input elements, use setSelectionRange() instead.
  • contenteditable="true" turns any element into an editable rich text area. Use Selection and Range to manipulate content, position cursors, insert formatted text, and build editor toolbars.

These APIs are the foundation for text editors, annotation tools, search highlighting, and any feature that needs to interact with user-selected content.