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 an exact part of the pageMake a Range with a start point and an end point. Each point is a node and an offset.
- 02Read what the user selectedCheck that a selection exists, then read its text, its anchor, its focus, and its Range.
- 03Change selected text safelyMove the cursor in a text box, and wrap selected page text in a mark element.
What does "selected text" mean to JavaScript?
When you drag the mouse across words on a page, the browser colors them blue. It also remembers where the selected part starts and where it ends. JavaScript can read those two places. Then it can copy the selected text, delete it, or wrap it in a new element. Quote buttons, comment tools, and text editors work this way.
This lesson covers three tools. Their names sound alike, but each one has its own job:
- A Range marks one exact part of the page. It has a start point, an end point, and everything between them.
- The Selection is what the user has selected on the page right now. It holds a Range. It also remembers which way the user dragged.
- Input and textarea selection is separate. A text box keeps its own cursor position as numbers, such as
selectionStart.
A Range marks one part of the page. The Selection is what the user selected right now. A text box keeps its own cursor, as two numbers.
You put one bookmark before a sentence and one after it. The book does not change. But now you can say "copy these pages" or "tear out these pages". A Range gives JavaScript the same two bookmarks inside a web page.
- In real life: The first bookmark
- In JavaScript: The start point:
startContainerandstartOffset - In real life: The second bookmark
- In JavaScript: The end point:
endContainerandendOffset - In real life: The pages between the bookmarks
- In JavaScript: The marked part:
range.toString()gives its text - In real life: The chapter that holds both bookmarks
- In JavaScript:
commonAncestorContainer: the smallest node that holds both points
You met nodes in the DOM tree lesson. A node is one piece of the page: an element such as <p>, or a piece of text inside it. You also learned to add and remove whole elements. This lesson works on a smaller piece: the exact words a reader selected.
A Range: a start point and an end point
INTERACTIVEYou make a Range with document.createRange(). A new Range is empty. You give it a start point with setStart(container, offset) and an end point with setEnd(container, offset). Each point needs two things:
- The container: the node the point sits in.
- The offset: a number that says how far into that node the point is.
What the offset counts depends on the kind of node. Read each row of the table as: "when the container is this kind of node, the offset counts these things."
| Kind of container | What the offset counts | Example |
|---|---|---|
| A text node, which is a piece of text. | UTF-16 code units: the small pieces JavaScript uses to store a string. | In "A😀B", the offsets are 0, 1, 3, and 4. The emoji 😀 takes two code units. |
An element node, such as <p>. | Child nodes: the nodes directly inside the element. | In <p>Hello <b>bold</b>!</p>, offset 1 is after the text Hello and before the <b>. |
A UTF-16 code unit is how JavaScript counts the length of a string. Most letters take one code unit. Many emoji take two. The Unicode & string internals lesson explains why. A Range counts the same way. It does not count the symbols you see on the screen.
const text = document.createTextNode("tea");const range = document.createRange();range.setStart(text, 0);range.setEnd(text, 3);console.log(range.toString(), range.collapsed);Line 1 makes a text node that holds "tea". Line 2 makes an empty Range. Line 3 puts the start point at offset 0, before the t. Line 4 puts the end point at offset 3, after the a. Line 5 prints tea false:
range.toString()gives the text between the two points:tea.range.collapsedistrueonly when the start and end are in the same place. Here they are not, so it isfalse.
t and offset 3 is after a, so the Range holds all of "tea".A text offset can't be bigger than the length of the text. An element offset can't be bigger than the number of child nodes. If it is, JavaScript throws an IndexSizeError. So read the limit from the node, for example its length. Don't guess it.
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 a start point and an end point to make a Range.
Try three things in the Range builder:
- Put the start and the end at the same place. The Range is now collapsed: it has no text, only a position.
- Choose
root elementas the container. Now the offset moves one child node at a time. - Choose a text node as the container. Now the offset moves one code unit at a time.
The second button uses the CSS Custom Highlight API. It is a newer browser tool that colors a Range without changing the page. If your browser doesn't have it, the demo uses the normal Selection instead.
The Selection: what the user selected
INTERACTIVEA reader presses a highlighter pen on the page, drags it, and lifts it. The browser remembers both ends of that drag. If the reader drags backwards, the same words get colored. But the place where the pen touched down and the place where it lifted off swap.
- In real life: Where the pen first touched the page
- In JavaScript: The anchor:
anchorNodeandanchorOffset - In real life: Where the pen lifted off
- In JavaScript: The focus:
focusNodeandfocusOffset - In real life: The colored words
- In JavaScript:
selection.toString()andgetRangeAt(0) - In real life: The pen touched down but did not move
- In JavaScript:
selection.isCollapsed === true: a cursor, no text
window.getSelection() and document.getSelection() both give you the page's Selection. A Selection usually holds zero Ranges or one Range. rangeCount tells you how many. Check it before you call getRangeAt(0). If there is no Range yet, that call throws an error.
Two words name the ends of the user's drag:
- The anchor is where the drag started.
- The focus is where the drag ended.
Newer browsers also have selection.direction. It says "forward" or "backward". Check that it exists before you use it.
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. A backwards drag selects the same words, but the anchor and the focus show where your drag started and ended.
- selection.toString()
- Select some words in the paragraph.
- anchor
- —
- focus
- —
- rangeCount
- 0
- isCollapsed
- true
- type
- None
- direction
- Checking…
Drag from left to right, then from right to left, over the same words.
The listener is on document, so it hears every selection on the page. The code ignores any Range outside the demo paragraph.
Select a word from left to right. Then select the same word from right to left. The selected text is the same both times. The Range's start and end are the same too, because a Range always keeps its start before its end. But the anchor and the focus swap. Text editors use this when you hold Shift and press an arrow key to grow a selection.
const selection = document.getSelection();selection.removeAllRanges();console.log(selection.rangeCount, selection.toString());Line 1 gets the Selection. Line 2 removes every Range from it, so nothing is selected. Line 3 prints 0 and an empty string. This is why your code should check rangeCount before it calls getRangeAt(0).
The browser fires a selectionchange event each time the selection changes. While the user drags, that can happen many times a second. So keep the code that runs small. Ignore selections outside your part of the page. Remove the listener when you no longer need it. Don't rebuild the page on every event.
Selection inside inputs and textareas
INTERACTIVEA text box is like a tiny text editor. Its cursor lives inside the text in the box. So the box tells you plain numbers, such as "the cursor is after letter 2".
- In real life: The blinking cursor
- In JavaScript:
selectionStart === selectionEnd - In real life: Some highlighted letters
- In JavaScript: From
selectionStartup toselectionEnd - In real life: Typing over the highlighted letters
- In JavaScript:
setRangeText(text, start, end, mode)
For <input> and <textarea>, you don't need nodes. Use the box's own tools:
selectionStartandselectionEnd: where the selected letters start and end. These are indexes, counted from 0.selectionDirection:"forward","backward", or"none".select(): selects all the text in the box.setSelectionRange(start, end): selects the letters from one index to another.setRangeText(text, start, end, mode): puts new text in place of the letters fromstarttoend.
Don't use a page Range for the text inside a text box. Browsers handle that in different ways. The box's own tools are made 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
These numbers come from the textarea itself, not from a page Range.
The third button calls setRangeText("😀", start, end, "end"). If no letters are selected, it puts the emoji in at the cursor. If some letters are selected, it replaces them. The last argument, "end", moves the cursor after the emoji. Emoji buttons, @mention pickers, and autocomplete use this.
const box = document.querySelector("#caret-demo");box.focus();box.setSelectionRange(box.value.length, box.value.length);console.log(box.selectionStart);The blinking cursor in a text box is also called the caret. Line 1 finds the textarea. Line 2 focuses it, so the caret shows. Line 3 uses the length of the text for both the start and the end. When the start and end are equal, no letters are selected; there is only a caret. So the caret sits after the last letter. The text "Type here" has 9 characters, so line 4 prints 9.
const box = document.querySelector("#caret-demo");box.setSelectionRange(2, 2);box.setRangeText("!", box.selectionStart, box.selectionEnd, "end");console.log(box.value, box.selectionStart, box.selectionEnd);Line 2 sets the start and the end both to 2. So the caret is after "Ty", and no letters are selected. Line 3 puts ! in at the caret and replaces nothing. The "end" mode moves the caret after the !. Line 4 prints Ty!pe here 3 3.
| is the caret. With start and end both 2, nothing is replaced: the ! goes in at the caret, and the caret moves after it, to 3.Build a highlighter
INTERACTIVEA highlighter wraps the selected words in a <mark> element. Browsers show the text inside <mark> with a yellow background. The steps are:
- Get the current Selection.
- Get its first Range.
- Check that the Range is inside the part of the page your code owns.
- Wrap the Range in a
<mark>.
Step 3 matters. Without it, a button in your widget could change text somewhere else on the page, such as the menu or this lesson's own text.
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 some words in the paragraph, then press Highlight selection.
The short way to wrap is range.surroundContents(mark). It works when the Range holds only whole elements. It throws an error when the Range cuts an element in half. For example, start a selection in plain text and end it in the middle of the bold words. Then the code uses a longer way:
range.extractContents()takes the selected part out of the page.mark.append(...)puts that part inside the mark.range.insertNode(mark)puts the mark back where the selected part was.
Only change the part of the page your code made. In these demos, the text that gets changed is built by plain DOM code inside an empty box. React, the tool that draws this page, never touches that box. So React and your code never change the same nodes.
A user can press Highlight twice on the same words. Decide what should happen then: nothing, remove the mark, or replace it. Otherwise each press puts another <mark> inside the last one, and the text gets harder to edit later.
Range methods that copy, remove, and insert
STEP THROUGHA Range has methods. Some of them move the Range's points:
selectNode(node)marks a whole node, tags and all.selectNodeContents(node)marks only what is inside the node.collapse(true)moves the end point to the start point.collapse(false)moves the start point to the end point.cloneRange()makes a new Range with the same start and end. Use it to save a position for later.
Other methods work on the content between the two points. In the replay below, each method starts with the same Range around the word magic. Pick a method and step through it. Before each step, guess: does the paragraph change?
A Range has a start point and an end point. Switch the method and guess which ones copy, remove, take out, or put in 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);cloneContents() and extractContents() both give back a DocumentFragment. A DocumentFragment is a small group of nodes that is not on the page.
magic. cloneContents() leaves the paragraph as it was; extractContents() takes the word out of it.Where you'll use this
These tools show up in anything that works like a text editor:
- Quote buttons that copy the selected words.
- Comments pinned to a few words in an article.
- Rich-text editors that make the selected words bold or turn them into a link.
- Highlighters and note tools.
- Search tools that color the words they found.
The steps are almost always the same: read the Selection, check it, then turn it into what 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 stops early when there is no Range, so getRangeAt(0) on line 3 can't throw. Line 4 stops when the selection is outside the article. After those two checks, line 6 puts the selected text into a preview box.
function selectedText() { const selection = window.getSelection(); return selection?.toString().trim() ?? "";} const range = document.createRange();range.selectNodeContents(article);const selection = window.getSelection();selection.removeAllRanges();selection.addRange(range);console.log(selectedText());Lines 1 to 4 make a helper function, selectedText(). On line 3:
?.stops and givesundefinedif there is no Selection..trim()removes spaces at the start and the end of the text.??swapsundefinedfor an empty string.
So the helper never throws. A Copy button can turn itself off when the text is empty. Lines 6 to 10 select the whole article. Line 11 prints the article's text.
selection.toString() only reads the text. To put it on the clipboard, call navigator.clipboard.writeText(text) when the user clicks. That call can fail if the browser or the user says no, so handle the error. A quote preview still works without the clipboard.
const paragraph = document.querySelector("#highlight-demo");const range = document.createRange();range.setStart(paragraph.firstChild, 0);range.setEnd(paragraph.firstChild, 9);const mark = document.createElement("mark");range.surroundContents(mark);console.log(paragraph.innerHTML);Line 3 puts the start at offset 0 of the text Highlight me. Line 4 puts the end at offset 9, right after Highlight. The space and me are left out. Line 5 makes a <mark>. The Range stays inside one text node, so surroundContents() on line 6 works. Line 7 prints <mark>Highlight</mark> me.
- Copy the nodes between two points on the page, without changing the page
- Show the text the user just dragged over in an article
- Put an emoji in at the cursor inside a
<textarea> - Know whether a drag started at the end and moved left
- Wrap selected page text in
<mark> - Select the characters from index 2 up to, but not including, index 5 in a text field
Sort each task by the tool you would use first.
Newer browsers have the CSS Custom Highlight API. Through CSS.highlights, it colors a Range without adding <mark> elements. Check that CSS.highlights exists before you use it, and have a backup plan. The Range builder above does this.
Common misconceptions
The three tools have similar names. Read each row of the table as: "this tool holds this, so use it for that."
| Tool | What it holds | What to use it for |
|---|---|---|
| Range | A start point and an end point in the page. | Copy, delete, take out, insert, wrap, or read one part of the page. |
| Selection | What the user selected on the page right now, or where the cursor is. | Read the selected text, the anchor and the focus, and the first Range. |
| Input selection | Two numbers inside the text of a text box. | Move the cursor in a text box, select letters, or put in new text. |
"A Range offset always counts characters."
Only inside a text node, and even there it counts code units, not the symbols you see. Inside an element, the offset counts child nodes. That is why, with the root element as the container in the Range builder, the offset jumps from the first text node, to the <b>, to the last text node.
"The anchor is always the Range's start."
A Range always keeps its start before its end. The anchor is where the user's drag started. After a backwards drag, the anchor is at the end of the selected words.
"I can use getSelection() for text boxes."
Use selectionStart and selectionEnd for inputs and textareas. They are simpler, and they are made for the text inside the box.
"surroundContents() always wraps whatever is selected."
It throws an error when the Range cuts through an element, such as half of a <b>. A good highlighter catches the error. Then it uses extractContents() and insertNode() instead.
"cloneRange() copies the selected text."
cloneRange() only copies the start point and the end point. To copy the selected nodes, use cloneContents().
Practice: select, read, and change
5 EXERCISESWhat does this program print? Write both values.
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) takes the code units at index 1 and 2. Together they make the emoji.
You're making an emoji button for a comment <textarea>. Which property tells you where the cursor starts?
Use textarea.selectionStart for where the selection starts, and textarea.selectionEnd for where it ends. When there is only a cursor, the two numbers are the same.
This program does with a string what extractContents() does with a Range. What is left 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 part taken out is magic. What is left is Range works, with two spaces. That is what the paragraph looks like after extractContents().
A user selects from plain text into only part of a bold word. Which Range method can throw an error then?
const mark = document.createElement("mark");
try {
range.surroundContents(mark);
} catch {
mark.append(range.extractContents());
range.insertNode(mark);
}range.surroundContents(mark) is short, but it can throw. Catch the error. Then take the selected part out with extractContents(), put it in the mark, and put the mark back with insertNode().
In the live selection inspector, select one word from left to right. Then select the same word from right to left. Write down which values changed and which stayed the same.
The selected text stays the same. The anchor and the focus swap, because the anchor is where the drag started and the focus is where it ended. A Range's start and end always stay in page order.
Quiz: check your understanding
7 QUESTIONSAnswer each question before you open the explanation. Each wrong answer also explains why it is wrong.
Question 1 of 7What is the start point or end point of a Range made of?
Choose an answer to see the explanation.
Question 2 of 7What length does this code print?
Read the code, then predictconst text = "A😀B"; console.log(text.length);Choose an answer to see the explanation.
Question 3 of 7What should you use to find 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 to its start. Which pair remembers that direction?
Choose an answer to see the explanation.
Question 5 of 7Which word does this 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 sentence about the page's Selection is true?
Choose an answer to see the explanation.
Key takeaways
- A Range has two points. Each point is a container node and an offset.
- In a text node, the offset counts UTF-16 code units. In an element, it counts child nodes.
- The Selection is what the user selected right now. Its anchor and focus show which way the user dragged.
- Inputs and textareas keep their own cursor, as
selectionStartandselectionEnd. - Before you change selected text, check that it is inside your part of the page. Catch errors from
surroundContents().
Selection & Range in one line.
A Range marks exactly where; the Selection is what the user selected right now; a text box keeps its own cursor numbers.
Up next: Introduction to events. You'll learn how your code can react when the user clicks, presses keys, or types.