ESC

Type to search the knowledge base.

Selection and Range APIs

Read and manipulate user selections with Selection and Range — caret position, surroundContents, and editor footguns.

advanced3 min read
  • javascript
  • selection-and

Browsers model highlighted text (and carets) with Selection and Range. Custom editors, “comment on highlight,” and formatting buttons all sit on these APIs. They’re powerful and easy to break with naive innerHTML rewrites.

Reading the selection

const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;

const range = sel.getRangeAt(0);
const text = range.toString();

console.log({
  collapsed: range.collapsed, // caret, no highlight
  startContainer: range.startContainer,
  startOffset: range.startOffset,
  endContainer: range.endContainer,
  endOffset: range.endOffset,
});

A Range is a boundary pair in the DOM tree (not just string indexes in the whole document).

Creating and applying ranges

const range = document.createRange();
range.selectNodeContents(paragraph);
range.collapse(true); // caret at start

const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
// Wrap selection in <mark>
const sel = window.getSelection();
if (!sel.rangeCount || sel.isCollapsed) return;
const range = sel.getRangeAt(0);
const mark = document.createElement('mark');
try {
  range.surroundContents(mark);
} catch {
  // partial element selection — surroundContents throws
  // extractContents + insertNode pattern instead
  mark.appendChild(range.extractContents());
  range.insertNode(mark);
}

Multi-range

Some browsers historically allowed multi-range selections; most UI code should assume one range (rangeCount check) unless you explicitly support multi.

contenteditable realities

editor.addEventListener('input', () => {
  // selection can jump after you mutate DOM
});

document.execCommand('bold'); // legacy — avoid in new design
// Prefer Input Events Level 2 / manual Range ops / editor framework

Mutating DOM under an active selection invalidates boundaries. Save range → mutate carefully → restore:

function saveSelection(root) {
  const sel = window.getSelection();
  if (!sel?.rangeCount) return null;
  const range = sel.getRangeAt(0);
  if (!root.contains(range.commonAncestorContainer)) return null;
  return range.cloneRange();
}

function restoreSelection(range) {
  if (!range) return;
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}

Still fragile across complex edits — production editors use model-based selections (ProseMirror, Slate, Lexical).

Coordinates for toolbars

const rect = range.getBoundingClientRect();
toolbar.style.top = `${rect.top + window.scrollY - 40}px`;
toolbar.style.left = `${rect.left + window.scrollX}px`;

getClientRects() returns multiple rects for multi-line selections.

Interview answer (out loud)

“Selection is the user-facing highlight; Range is the DOM boundary model. I read getSelection, getRangeAt(0), and toString for text. surroundContents fails on partial nodes so I extractContents. For serious editors I don’t rebuild HTML with innerHTML — I use a document model because ranges break easily.”

Collapsed ranges (caret)

const sel = getSelection();
if (sel && sel.isCollapsed) {
  // insertion point only — still a Range with equal start/end
  const rects = sel.getRangeAt(0).getClientRects();
  // may be empty in some edge cases — fall back to element rect
}

Toolbar “insert link” at caret uses collapsed ranges.

Security: don’t trust selection HTML

const html = range.cloneContents(); // DocumentFragment of live-ish nodes
// serializing to HTML and re-injecting can create XSS if mixed with untrusted

If you export selection as HTML for a comment quote, sanitize before innerHTML.

Shadow DOM note

Selections inside shadow roots are trickier; getSelection() behavior across shadow boundaries has evolved. Component editors often keep an internal model rather than relying on cross-root selection.

Further reading

Related guides