Range API

Comprehensive guide to the Range API for manipulating document content in contenteditable elements.

Overview

The Range API provides methods to select, extract, insert, and manipulate content in the DOM. Unlike the Selection API (which represents the user's current selection), Range objects are programmatic and can be created, modified, and applied to selections.

Key Concepts:

  • A Range represents a contiguous portion of the document
  • Ranges are defined by start and end positions (node + offset)
  • Ranges can be collapsed (empty, like a cursor position)
  • Ranges can be used to extract, clone, insert, or delete content
  • Ranges must be applied to Selection to become visible to the user

Creating Ranges

Create a new Range object using document.createRange(). The range is initially empty and collapsed.

// Create a new Range object
const range = document.createRange();

// Range is initially empty and not attached to the document
console.log(range.collapsed); // true

Setting Range Boundaries

Use setStart() and setEnd() to define the range boundaries. Both methods take a node and an offset within that node.

const range = document.createRange();
const textNode = element.firstChild; // Assuming first child is a text node

// Set start position: node, offset
range.setStart(textNode, 5);  // Start at character 5
range.setEnd(textNode, 10);   // End at character 10

// Now the range covers characters 5-10
console.log(range.toString()); // Text from position 5 to 10

⚠️ Offset Behavior

Offset depends on node type:

  • Text nodes: Offset is character position (0 to text.length)
  • Element nodes: Offset is child index (0 to childNodes.length)

Setting an offset beyond the node's length will throw an error.

Selecting Nodes

Use selectNode() to select an entire element, or selectNodeContents() to select only its children.

// Select an entire element node
const range = document.createRange();
range.selectNode(element); // Selects the element and all its children

// Or select only the contents (children, not the element itself)
range.selectNodeContents(element); // Selects all children

Difference: selectNode vs selectNodeContents

selectNode(element)

Selects the element itself, including the element and all its children. The range starts before the element and ends after it.

Example:
<div>Hello</div>
Range includes: <div>Hello</div>

selectNodeContents(element)

Selects only the children of the element, not the element itself. The range starts at the first child and ends at the last child.

Example:
<div>Hello</div>
Range includes: Hello (text node only)

Extracting Content

extractContents() removes the range's content from the DOM and returns it as a DocumentFragment.

// Extract selected content from DOM (removes it)
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

const fragment = range.extractContents(); // Returns DocumentFragment, removes from DOM
// fragment contains the extracted nodes

Important: After extraction, the range becomes collapsed at the extraction point. The original content is removed from the DOM.

Cloning Content

cloneContents() creates a copy of the range's content without removing it from the DOM.

// Clone selected content (keeps original in DOM)
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

const fragment = range.cloneContents(); // Returns DocumentFragment, original stays
// fragment contains cloned nodes

Use cloneContents() when you need to copy content without modifying the original DOM, such as for undo/redo operations or preview functionality.

Inserting Nodes

insertNode() inserts a node at the start of the range. The range expands to include the inserted node.

// Insert a node at the start of the range
const range = document.createRange();
range.setStart(textNode, 5);
range.setEnd(textNode, 10);

const newNode = document.createTextNode('INSERTED');
range.insertNode(newNode); // Inserts before the range start

Note: The inserted node is placed immediately before the range start. If you want to replace the range content, extract it first, then insert the new node.

Deleting Content

deleteContents() removes all content within the range. The range becomes collapsed at the deletion point.

// Delete content within the range
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

range.deleteContents(); // Removes content, range becomes collapsed

Collapsing Ranges

collapse() collapses the range to a single point (like a cursor position).

// Collapse range to start or end
const range = document.createRange();
range.setStart(textNode, 5);
range.setEnd(textNode, 10);

range.collapse(true);  // Collapse to start (position 5)
range.collapse(false); // Collapse to end (position 10)

Comparing Ranges

compareBoundaryPoints() compares the boundaries of two ranges.

// Compare ranges
const range1 = document.createRange();
const range2 = document.createRange();

// Compare start of range1 with start of range2
const result = range1.compareBoundaryPoints(
  Range.START_TO_START,
  range2
);

// Returns: -1 (range1 before range2), 0 (same), 1 (range1 after range2)

Comparison Constants

  • Range.START_TO_START - Compare start of range1 with start of range2
  • Range.START_TO_END - Compare start of range1 with end of range2
  • Range.END_TO_START - Compare end of range1 with start of range2
  • Range.END_TO_END - Compare end of range1 with end of range2

Getting Visual Positions

getClientRects() returns a list of rectangles representing the visual position of the range. Useful for positioning UI elements relative to selections.

// Get visual rectangles for the range
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

const rects = range.getClientRects(); // Returns DOMRectList
// Each rect represents a visual box (for multi-line selections)

For multi-line selections, getClientRects() returns multiple rectangles (one per line). Use getBoundingClientRect() for a single bounding rectangle.

Common Patterns

Here's a common pattern for wrapping selected text in a formatting element:

// Common pattern: Wrap selected text
function wrapSelection(tagName) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  const wrapper = document.createElement(tagName);
  
  try {
    const contents = range.extractContents();
    wrapper.appendChild(contents);
    range.insertNode(wrapper);
    
    // Update selection to the new wrapper
    selection.removeAllRanges();
    const newRange = document.createRange();
    newRange.selectNode(wrapper);
    selection.addRange(newRange);
  } catch (e) {
    console.error('Failed to wrap selection:', e);
  }
}

Platform-Specific Issues & Edge Cases

Range API behavior can vary significantly depending on browser, OS, device, and keyboard type. These variations can cause unexpected behavior when manipulating ranges.

Browser-Specific Issues

⚠️ Safari: Range Boundary Issues

Safari: Range boundaries may be reported incorrectly when selecting across multiple elements or complex DOM structures.

  • getClientRects() may return incorrect positions for multi-line selections
  • Range offsets may be off by one in some edge cases
  • Selecting across element boundaries can produce unexpected results

⚠️ Chrome/Edge: IME Composition Ranges

Chrome/Edge: During IME composition, Range objects may behave unexpectedly:

  • Range boundaries may include composition text in unexpected ways
  • extractContents() during composition may fail or produce incorrect results
  • Range operations may need to wait until compositionend

⚠️ Firefox: Cross-Element Selection

Firefox: Selecting across element boundaries may produce different Range structures than Chrome/Edge:

  • Range start/end containers may differ for the same visual selection
  • cloneContents() may include or exclude element boundaries differently

OS & Keyboard-Specific Issues

⚠️ macOS + Korean IME: Range Operations During Composition

macOS + Korean IME: Range operations during active IME composition may fail or produce incorrect results:

  • extractContents() may throw errors or return incomplete fragments
  • Range boundaries may not accurately reflect composition text position
  • Operations should be deferred until after compositionend

⚠️ Windows: IME Provider Differences

Windows: Different IME providers (Microsoft IME, Google IME, etc.) may affect Range behavior:

  • Range operations during composition may vary by IME provider
  • Windows 10 vs Windows 11 may have different Range behavior

⚠️ Keyboard Layout: Range Offsets

Different keyboard layouts: Korean IME layouts (2벌식, 3벌식, 390 자판) may affect Range offset calculations:

  • Character offsets may differ due to composition method
  • Range boundaries during composition may vary between layouts

Device-Specific Issues

⚠️ Mobile: Touch Selection Ranges

Mobile devices: Touch-based selection creates Ranges differently than mouse selection:

  • Range boundaries may be less precise on mobile
  • getClientRects() may return incorrect positions due to viewport scaling
  • Virtual keyboard may interfere with Range operations
  • Android vs iOS may handle touch selection ranges differently

⚠️ Tablet: Hybrid Behavior

Tablets: Range behavior may switch between desktop and mobile patterns:

  • External keyboard: behaves like desktop
  • Touch input: behaves like mobile
  • Range operations may need to detect input method

General Edge Cases

Range behavior varies: Some Range methods may behave differently across browsers, especially when dealing with complex DOM structures, IME composition, or edge cases (empty nodes, collapsed ranges, etc.).

Error handling: Range methods can throw errors if given invalid parameters (e.g., offset beyond node length, invalid node references). Always wrap Range operations in try-catch blocks.

DOM mutations: After extracting or deleting content, the DOM structure changes. Any stored node references may become invalid. Always re-query the DOM after Range operations.

Related resources