Selection and Range APIs
Read and manipulate user selections with Selection and Range — caret position, surroundContents, and editor footguns.
- 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
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.
- Async Iteration and for await...ofAsync iterables, for await...of, and streaming data — how async iterators differ from Promise.all and when to use each.