Selection & Range
Learn how Range objects mark pieces of the DOM, how the Selection API reads what a user selected, how text box selection differs, and how to build a safe highlighter.
- 01Mark exact DOM passagesCreate Range boundaries with container nodes and offsets.
- 02Inspect the live selectionRead anchor, focus, ranges, and selected text safely.
- 03Edit selections carefullyHandle textarea cursors and wrap selected page text in marks.
What does “selected text” mean to JavaScript?
When someone drags across words on a page, the browser does more than paint blue pixels. It remembers where the selected passage begins and ends in the DOM tree. JavaScript can read that information, move it, copy it, delete it, or use it to build features like quotes, annotations, and rich-text editors.
This lesson separates three ideas that sound similar but behave differently:
- A Range is a precise DOM passage: start boundary, end boundary, and all the content between them.
- The Selection is the live thing the user currently selected in the document. It can point at a Range and also remembers drag direction through anchor and focus.
- Input and textarea selection is tracked by the text control itself with
selectionStartand friends.
A Range marks a passage; a Selection is the user’s current highlighter; a textarea cursor is a separate cursor inside a text box.
Imagine placing one bookmark before a sentence and one after it. The book did not change, but you can now say “copy this passage,” “delete this passage,” or “wrap this passage in yellow paper.” A Range gives JavaScript the same precise handles inside the DOM.
- In real life: The first bookmark
- In JavaScript:
startContainerplusstartOffset - In real life: The second bookmark
- In JavaScript:
endContainerplusendOffset - In real life: The passage between them
- In JavaScript:
range.toString()or the selected DOM nodes - In real life: The chapter containing both bookmarks
- In JavaScript:
commonAncestorContainer
Where the analogy stops: A paper bookmark sits between printed characters. A Range boundary sits inside a DOM node: text-node offsets count UTF-16 code units, while element offsets count child nodes.
You have already met DOM nodes in The DOM tree and node-changing tools in Creating & changing elements. Here we zoom in: instead of changing a whole element, we target the exact part the reader selected.
Range objects: two boundaries, one passage
INTERACTIVECreate a Range with document.createRange(). At first it is just an empty object with movable boundaries. You place those boundaries with setStart(container, offset) and setEnd(container, offset).
| Container node | What the offset counts | Example |
|---|---|---|
| Text node | UTF-16 code units | In "A😀B", offsets are 0, 1, 3, 4 because 😀 takes two code units. |
| Element node | Child nodes | In <p>Hello <b>bold</b>!</p>, offset 1 is after the first text node and before the <b>. |
That UTF-16 detail is the same string rule covered in Unicode & string internals. It matters because Range boundaries are low-level: they talk to the DOM, not to what a human sees as “one character.”
const root = document.querySelector("#demo");const range = document.createRange();range.setStart(root.childNodes[0], 6);range.setEnd(root.childNodes[2], 5);console.log(range.toString());getSelection().removeAllRanges();getSelection().addRange(range);- range.toString()
- —
- start
- —
- end
- —
- collapsed
- —
- commonAncestorContainer
- —
Choose boundaries to create a Range.
Try three experiments: make the start and end the same boundary to see a collapsed Range; choose the root element and move by child nodes; then choose a text node and move by code units. If your browser supports the CSS Custom Highlight API, the highlight mode paints a Range without changing the DOM. If not, the demo falls back to the normal Selection.
The Selection API: the user’s highlighter
INTERACTIVEWhen a reader drags across text, imagine a highlighter touching down, moving, and lifting up. The browser remembers both ends of that gesture. This is why a backwards drag can have the same selected words but different anchor and focus values.
- In real life: Where the reader first pressed down
- In JavaScript:
anchorNodeandanchorOffset - In real life: Where the reader let go
- In JavaScript:
focusNodeandfocusOffset - In real life: The colored passage
- In JavaScript:
selection.toString()andgetRangeAt(0) - In real life: A caret with no highlighted text
- In JavaScript:
selection.isCollapsed === true
Where the analogy stops: A real highlighter leaves ink behind. The Selection is temporary browser state: click elsewhere and it can move or disappear.
window.getSelection() and document.getSelection() both access the document Selection in browser page code. Before calling getRangeAt(0), check rangeCount; there may be no selected Range yet. The newer selection.direction is useful where supported, but anchor and focus are the classic way to see where a drag began and ended.
document.addEventListener("selectionchange", () => { const selection = document.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); if (!demo.contains(range.commonAncestorContainer)) return; console.log(selection.toString()); console.log(selection.anchorNode, selection.anchorOffset); console.log(selection.focusNode, selection.focusOffset);});Start at either end: selecting text backwards keeps the same selected words, but anchor and focus reveal where your drag began and ended.
- selection.toString()
- Select inside the demo paragraph.
- anchor
- —
- focus
- —
- rangeCount
- 0
- isCollapsed
- true
- type
- None
- direction
- Feature-detecting…
Drag forwards, then drag backwards over the same words.
The listener is on document, but the code ignores selections whose Range belongs outside the demo paragraph.
Select a word, then drag from right to left over that same word. The Range’s start and end remain in document order, but anchor and focus reflect the gesture. Real editors care about this when extending a selection with Shift+Arrow keys.
Selection in inputs and textareas
INTERACTIVEA text field behaves like a tiny editor. Its cursor lives inside the control’s value, so the control gives you direct numeric indexes instead of asking you to inspect DOM nodes.
- In real life: The blinking cursor
- In JavaScript:
selectionStart === selectionEnd - In real life: A highlighted run of characters
- In JavaScript:
selectionStarttoselectionEnd - In real life: Replace the highlighted run
- In JavaScript:
setRangeText(text, start, end, mode)
Where the analogy stops: The text box exposes string indexes, not DOM nodes. A textarea’s internal text is not the same as selecting text in normal page content.
For <input> and <textarea>, use selectionStart, selectionEnd, selectionDirection, select(), setSelectionRange(), and setRangeText(). Do not rely on normal document Ranges for text inside form controls; browsers have differed here, while the form-control API is designed for this job.
const box = document.querySelector("textarea"); box.select();box.setSelectionRange(2, 5, "forward");box.setRangeText("😀", box.selectionStart, box.selectionEnd, "end"); console.log(box.selectionStart);console.log(box.selectionEnd);console.log(box.selectionDirection);- selectionStart
- 0
- selectionEnd
- 0
- selectionDirection
- none
The numbers come from the textarea itself. They are not a normal document Range.
Notice that setRangeText("😀", start, end, "end") can insert at a caret or replace a selected slice, then move the caret after the inserted emoji. That is perfect for mention pickers, emoji buttons, snippet expanders, and autocomplete chips.
Building a highlighter
INTERACTIVEA highlighter begins with the current Selection, extracts its first Range, checks that the Range belongs to the part of the page you own, and wraps the selected content in a <mark> element. That containment check is important: without it, a button in your widget could accidentally rewrite a selection from the lesson text, the navigation, or another component.
const selection = document.getSelection();const range = selection.getRangeAt(0);if (!container.contains(range.commonAncestorContainer)) return; const mark = document.createElement("mark");try { range.surroundContents(mark);} catch { mark.append(range.extractContents()); range.insertNode(mark);} container.querySelectorAll("mark").forEach((mark) => { mark.replaceWith(...mark.childNodes);});container.normalize();Select text in the demo paragraph, then press Highlight.
The easiest path is range.surroundContents(mark). It works when the Range fully contains every non-text node it touches. It throws when the Range partially contains an element, such as selecting from the middle of normal text into only part of a <b>. The fallback is more manual but more forgiving: extract the selected contents, append them to the mark, and insert the mark back at the Range.
Only mutate DOM your component owns. In the demos, anything that inserts or removes nodes uses an empty ref container, then builds the contents with DOM APIs so React does not try to reconcile children it no longer recognizes.
Range surgery methods
STEP THROUGHRanges are useful because they carry methods that operate on the selected passage. selectNode(node) selects a node itself. selectNodeContents(node) selects what is inside it. collapse(true) collapses to the start; collapse(false) collapses to the end. cloneRange() copies the boundaries so you can keep a saved position.
The content methods are where things get exciting. Step through the same Range four ways and predict whether the paragraph changes.
A Range is two boundaries. Switch the operation and predict which methods copy, remove, move, or insert content.
script
const text = paragraph.firstChild;const range = document.createRange();range.setStart(text, 6);range.setEnd(text, 11);const copy = range.cloneContents();console.log(copy.textContent);Where you’ll use this
Selection and Range APIs show up in tools that feel “editor-like”: quote buttons, comment anchors, rich-text editors, annotation layers, search results, and accessibility helpers that report what the user is reading. The pattern is almost always: read the Selection, guard carefully, then convert it to the form your feature needs.
const selection = document.getSelection();if (!selection || selection.rangeCount === 0) return;const range = selection.getRangeAt(0);if (!article.contains(range.commonAncestorContainer)) return; quotePreview.textContent = selection.toString();Line 2 prevents a crash when there is no Range. Line 4 prevents your feature from reading or changing selections outside the article. After those checks, selection.toString() is safe to put into a preview, a share card, or a comment composer.
- Copy the nodes between two DOM boundaries without changing the page
- Show the text the user just dragged over in an article
- Insert an emoji at the cursor inside a
<textarea> - Know whether a drag started at the end and moved left
- Wrap selected page content in
<mark> - Select characters 2 through 5 in a text field
Sort each realistic task by the API you would reach for first.
The CSS Custom Highlight API lets newer browsers paint Ranges through CSS.highlights without inserting <mark> elements. Feature-detect it and keep a fallback, as the Range builder does.
Common misconceptions
| Thing | Owns | Use it for |
|---|---|---|
| Range | Start/end DOM boundaries | Copy, delete, extract, insert, wrap, or inspect a passage. |
| Selection | The user’s current document highlight/caret | Read selected page text, anchor/focus, and the first Range. |
| Input selection | Indexes inside a form control value | Move a textarea cursor, select typed characters, or insert replacement text. |
“A Range offset is always a character index.”
Only inside Text nodes. On element containers, offsets count child nodes. That is why choosing the root element in the Range builder jumps between the text node, the <b>, and the trailing text node.
“Selection start and Range start are the same idea.”
Range start/end are ordered in the document. Selection anchor/focus remember the user’s gesture, so they can reveal a backwards drag.
“I can use getSelection for textareas everywhere.”
Use selectionStart and selectionEnd for form controls. They are simpler and designed for that text value.
“surroundContents always wraps whatever is selected.”
It throws if the Range partially contains a non-Text node. Good highlighters catch that and fall back to extract/insert.
“Cloning a Range clones the selected DOM.”
cloneRange() copies the boundaries. Use cloneContents() to copy the selected nodes.
Practice: select, inspect, and edit
5 EXERCISESPredict the two values printed by this program.
const word = "A😀B";
console.log(word.length);
console.log(word.slice(1, 3));A is one code unit, 😀 is two, and B is one, so length is 4. slice(1, 3) captures the two code units that make the emoji.
You are writing an emoji button for a comment <textarea>. Which property tells you the cursor’s start index?
Use textarea.selectionStart for the start index and textarea.selectionEnd for the end index. For a collapsed cursor they are the same.
What remains in text after line 3?
let text = "Range magic works";
const selected = text.slice(6, 11);
text = text.slice(0, 6) + text.slice(11);
console.log(selected);
console.log(text);The selected text is magic. Removing offsets 6–11 leaves Range works, which mirrors the mutation part of extractContents().
Which Range method can throw when the user selects from plain text into only part of a bold element?
const mark = document.createElement("mark");
try {
range.surroundContents(mark);
} catch {
mark.append(range.extractContents());
range.insertNode(mark);
}range.surroundContents(mark) is convenient, but it can throw. Catch the error, extract the selected contents into the mark, then insert the mark back into the Range.
Use the selection inspector to select the same word forwards and backwards. Write down which fields changed and which stayed the same.
The selected text can be the same as a forward drag, but anchor and focus swap because anchor marks where the gesture began and focus marks where it ended. Range start and end stay in document order.
Quiz: check your understanding
7 QUESTIONSAnswer each question before opening the explanation. Wrong answers are written to teach the difference, not just mark a miss.
Question 1 of 7What is a Range boundary made of?
Choose an answer to see the explanation.
Question 2 of 7What does the emoji length code print?
Read the code, then predictconst text = "A😀B"; console.log(text.length);Choose an answer to see the explanation.
Question 3 of 7Which API should you use for the cursor inside a textarea?
Choose an answer to see the explanation.
Question 4 of 7A user drags from the end of a sentence back toward the start. Which pair remembers that direction?
Choose an answer to see the explanation.
Question 5 of 7What does the slice code print?
Read the code, then predictconst text = "Range magic works"; console.log(text.slice(6, 11));Choose an answer to see the explanation.
Question 6 of 7When does
range.surroundContents(mark)throw?Choose an answer to see the explanation.
Question 7 of 7Which statement is safest for page selections?
Choose an answer to see the explanation.
Key takeaways
- A Range is two boundaries: start container/offset and end container/offset.
- Text-node offsets count UTF-16 code units; element offsets count child nodes.
- The Selection API reads the live document selection; anchor/focus can reveal a backwards drag.
- Inputs and textareas use their own selection properties and methods.
- When editing selected DOM, guard the container and handle
surroundContents()failures.
Selection & Range in one line.
A Range marks exactly where; the Selection says what the user is holding right now; text controls track their own cursor indexes.
Up next: Introduction to events, where you will learn how listeners and event objects let page code respond to clicks, keys, input, and more.