Selection and Range
Text selection sounds trivial until you try to control it from code. Highlight some words, wrap them in a tag, drop the cursor at exactly character 10, copy a fragment while keeping its bold and italic formatting — each of those is a small job, and the browser gives you two APIs to do them.
This chapter covers both: selection inside the document (any DOM node) and selection inside form fields like <input> and <textarea>. With them you can read whatever the user has highlighted, select or deselect nodes yourself, delete the selected content, or wrap it in an element.
There’s a recipe collection in the Summary at the end. It may cover exactly what you need today. But the two objects underneath — Range and Selection — are small enough to actually understand, and once they click you won’t need recipes at all.
Range
The core idea behind any selection is a Range: a pair of boundary points, a start and an end. Nothing more. A range doesn’t draw anything on screen and doesn’t touch the page. It’s a lightweight description of “this spot to that spot.”
You create one with no arguments:
let range = new Range();
Then you place its two boundaries with range.setStart(node, offset) and range.setEnd(node, offset).
We’ll feed these ranges to the selection later. First, let’s get comfortable building them, because the offset argument behaves in two different ways depending on what node is.
Selecting text partially
The node you pass to setStart / setEnd can be a text node or an element node, and that choice changes what offset means.
When node is a text node, offset is a character position inside that text.
Positions sit between characters, like the gaps you’d move a text cursor through. For the word Cloud there are six positions, 0 through 5:
So, given <p>Cloud</p>, here’s a range that holds the two letters ou:
<p id="p">Cloud</p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.firstChild, 4);
// a range stringifies to its content as text
console.log(range); // ou
</script>
p.firstChild is the text node holding Cloud. Start at character position 2, end at position 4, and the range spans the slice in between.
Selecting element nodes
When node is an element node, offset is a child index. Position 0 is before the first child, 1 is before the second, and so on.
This is the way to grab whole nodes as units, without stopping partway through any text. Take a richer fragment:
<p id="p">Menu: <i>soup</i> and <b>cake</b></p>
Its DOM isn’t flat. The <p> has four children — text, element, text, element — and the two elements have their own text nodes inside:
Say we want a range for Menu: <i>soup</i>. That’s exactly the first two children of <p>, indexes 0 and 1:
“Menu: “
soup
” and “
cake
- The start point uses
<p>as itsnodeand0as the offset →range.setStart(p, 0). - The end point also uses
<p>, but offset2. The end offset is exclusive — the range runs up to but not including child index2→range.setEnd(p, 2).
Run this and the text highlights on screen:
<p id="p">Menu: <i>soup</i> and <b>cake</b></p>
<script>
let range = new Range();
range.setStart(p, 0);
range.setEnd(p, 2);
// a range stringifies to its content as text, without tags
console.log(range); // Menu: soup
// hand this range to the document selection (covered below)
document.getSelection().addRange(range);
</script>
Here’s a version you can poke at — change the start and end numbers and watch what gets selected:
Selecting from offset 1 to 4 in the same <p> gives the range <i>soup</i> and <b>cake</b> — children 1, 2, and 3:
“Menu: “
soup
” and “
cake
Selecting a bigger fragment
Now mix the two modes. Suppose we want to grab nu: soup and cak — starting partway into the first text node and ending partway into the bold text:
The boundaries live at the text level, so both use text nodes:
- start at position 2 in
<p>’s first child — skipMe, keep the rest ofMenu: - end at position 3 in
<b>’s first child — keepcakofcake, drop the trailinge
<p id="p">Menu: <i>soup</i> and <b>cake</b></p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
console.log(range); // nu: soup and cak
// use this range for selection (explained below)
window.getSelection().addRange(range);
</script>
That’s the whole toolkit. Pass elements to setStart / setEnd when you want whole nodes; pass text nodes when you want to cut inside the text.
Range properties
The range built just above exposes these read-only properties:
<p><b><p>startContainer,startOffset— the node and offset of the start. Here,<p>’s first text node and2.endContainer,endOffset— the node and offset of the end. Here,<b>’s first text node and3.collapsed— a boolean,truewhen start and end sit at the exact same point, meaning the range holds nothing. Here it’sfalse.commonAncestorContainer— the nearest ancestor that contains every node the range touches. Here it’s<p>.
Range methods for moving the boundaries
There’s a family of methods for positioning the two boundaries. You’ve met setStart / setEnd; the rest are convenience shortcuts.
Set the start:
setStart(node, offset)— start at positionoffsetinsidenodesetStartBefore(node)— start immediately beforenodesetStartAfter(node)— start immediately afternode
Set the end, with matching variants:
setEnd(node, offset)— end at positionoffsetinsidenodesetEndBefore(node)— end immediately beforenodesetEndAfter(node)— end immediately afternode
Technically setStart / setEnd alone can express any boundary. The Before / After variants just save you from computing child indexes by hand.
In all of them, node may be a text node or an element node. As before: for a text node offset counts characters, for an element node it counts child nodes.
A few more methods build a whole range in one call:
selectNode(node)— make the range wrapnodeitself (including its tags)selectNodeContents(node)— make the range wrap everything insidenode, tags excludedcollapse(toStart)— squash the range to a single point:toStart=truemoves the end onto the start, otherwise the start onto the endcloneRange()— return a fresh range with the same start and end
Range methods for editing content
Once a range is placed, these methods act on the DOM it covers:
deleteContents()— remove the range’s content from the documentextractContents()— remove it from the document and hand it back as a DocumentFragmentcloneContents()— copy the content and return it as a DocumentFragment, leaving the document untouchedinsertNode(node)— insertnodeat the start of the rangesurroundContents(node)— wrapnodearound the range’s content. This needs the range to hold complete elements — both the opening and closing tag of everything inside. A partial range like<i>abc(open tag without its close) throws.
Between them you can delete, cut, copy, inject, or wrap any selected fragment. Here’s a test stand that wires each method to a button:
There are also methods for comparing two ranges, but they come up rarely. When you need one, reach for the DOM spec or the MDN Range reference.
Selection
A Range describes boundaries, but on its own it’s invisible. You can build ranges, store them, pass them to functions, and the page shows nothing.
What the user actually sees highlighted is the document’s Selection object. Grab it with window.getSelection() or document.getSelection() — same thing. A selection holds zero or more ranges. The Selection API spec allows several. In practice, only Firefox lets a user build a multi-range selection with Ctrl+click (Cmd+click on Mac):
The quick brown fox jumps over the lazy dog.
Every other browser caps a selection at a single range. Some Selection methods are written as if there could be many, but outside Firefox you’re always dealing with at most one.
This small demo shows the current selection as text — highlight something on the page, then click:
<button onclick="alert(document.getSelection())">alert(document.getSelection())</button>
Selection properties
A selection can, in theory, hold multiple ranges, and you reach each one with:
getRangeAt(i)— get the i-th range, counting from0. Everywhere except Firefox, only0ever exists.
Working through ranges is verbose, so the selection also exposes handier properties. A selection has a start called the anchor and an end called the focus:
anchorNode— the node where the selection startsanchorOffset— the offset insideanchorNodewhere it startsfocusNode— the node where the selection endsfocusOffset— the offset insidefocusNodewhere it endsisCollapsed—trueif the selection is empty (or absent)rangeCount— how many ranges are in the selection; at most1outside Firefox
Selection events
Two events let you follow what’s being selected:
elem.onselectstart— fires when a selection begins onelem(or inside it), for example the moment the user presses the mouse on it and starts to drag.- Calling
preventDefault()(or returningfalse) here cancels the start. The user then can’t begin a selection from this element, though the element stays selectable — they just have to start dragging from somewhere else.
- Calling
document.onselectionchange— fires whenever the selection changes or starts.- This one only works on
document. It watches every selection in the page.
- This one only works on
Tracking the selection
A small demo that reads the live selection off document and displays its two boundaries:
Copying the selection
There are two ways to copy what’s selected:
document.getSelection().toString()gives you plain text.- To keep the full DOM — formatting and all — pull the underlying ranges with
getRangeAt(...). EachRangehascloneContents(), which copies its content into aDocumentFragmentyou can drop anywhere.
This demo copies the selection both ways at once:
Selection methods
You can edit a selection by adding and removing ranges:
getRangeAt(i)— get the i-th range, from0. Only0matters outside Firefox.addRange(range)— addrange. Every browser except Firefox ignores this call if the selection already has a range.removeRange(range)— removerange.removeAllRanges()— remove every range.empty()— an alias forremoveAllRanges.
There’s also a set of convenience methods that move the selection directly, no intermediate Range needed:
collapse(node, offset)— replace the selection with an empty range positioned atnode/offsetsetPosition(node, offset)— alias forcollapsecollapseToStart()— collapse to the current selection’s startcollapseToEnd()— collapse to the current selection’s endextend(node, offset)— move the focus tonode/offset, leaving the anchor putsetBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)— replace the selection outright with the given start and end; everything between them becomes selectedselectAllChildren(node)— select all children ofnodedeleteFromDocument()— remove the selected content from the documentcontainsNode(node, allowPartialContainment = false)— test whether the selection containsnode(partial matches count when the second argument istrue)
For most work these are enough; you rarely touch the raw Range. Selecting the whole contents of a paragraph, for instance:
<p id="p">Select me: <i>soup</i> and <b>cake</b></p>
<script>
// from the 0th child of <p> up to the last child
document.getSelection().setBaseAndExtent(p, 0, p, p.childNodes.length);
</script>
The same result via a range:
<p id="p">Select me: <i>soup</i> and <b>cake</b></p>
<script>
let range = new Range();
range.selectNodeContents(p); // or selectNode(p) to include the <p> tag itself
document.getSelection().removeAllRanges(); // clear any existing selection
document.getSelection().addRange(range);
</script>
Selection in form controls
<input> and <textarea> get their own selection API, with no Range or Selection objects in sight. Their value is plain text rather than HTML, so a boundary is just a character index — much simpler.
Properties:
input.selectionStart— start position (writable)input.selectionEnd— end position (writable)input.selectionDirection—"forward","backward", or"none"(the last one happens, for example, on a double-click select)
Event:
input.onselect— fires when something gets selected
Methods:
-
input.select()— select everything in the control (textareatoo) -
input.setSelectionRange(start, end, [direction])— select fromstarttoend, optionally in a given direction -
input.setRangeText(replacement, [start], [end], [selectionMode])— replace a stretch of text withreplacement.If you pass
startandend, they define the stretch; without them the current user selection is used. The last argument,selectionMode, decides where the selection lands afterward:"select"— the inserted text ends up selected"start"— the selection collapses to just before the inserted text (cursor before it)"end"— the selection collapses to just after it (cursor after it)"preserve"— tries to keep the previous selection. This is the default.
Here’s a picture of the character indexes for a text field:
Now let’s put these to work.
Example: tracking selection
This reads the selection through the onselect event:
<textarea id="area" style="width:80%;height:60px">
Selecting in this text updates the values below.
</textarea>
<br>
From <input id="from" disabled> – To <input id="to" disabled>
<script>
area.onselect = function() {
from.value = area.selectionStart;
to.value = area.selectionEnd;
};
</script>
Two things to keep in mind:
onselectfires when text becomes selected, but not when the selection is cleared.document.onselectionchangeshould not fire for selections inside a form control, per the spec — that event is about document selection, not form fields. A few browsers fire it anyway, so don’t build on it.
Example: moving the cursor
Both selectionStart and selectionEnd are writable, so assigning to them sets the selection.
The interesting edge case: when selectionStart === selectionEnd, the “selection” has zero width, which is exactly the text cursor (caret). Nothing is selected — the selection is collapsed at that point.
So set both to the same number and you move the cursor there:
<textarea id="area" style="width:80%;height:60px">
Click here and the caret jumps to position 10.
</textarea>
<script>
area.onfocus = () => {
// zero-delay setTimeout runs after the browser's own focus handling
setTimeout(() => {
// set any selection you like
// start === end means the cursor sits exactly there
area.selectionStart = area.selectionEnd = 10;
});
};
</script>
Example: modifying selection
To rewrite the selected text, use input.setRangeText(). You could read selectionStart/End, splice value by hand, and reassign it — but setRangeText does more and reads cleaner.
It’s a dense method. In its simplest one-argument form it replaces the user’s selection and clears it. Here every selected stretch gets wrapped in *...*:
Pass more arguments to target an explicit range. This one finds "WORD", replaces it, and keeps the replacement selected:
<input id="input" style="width:200px" value="Replace WORD in text">
<button id="button">Replace WORD</button>
<script>
button.onclick = () => {
let pos = input.value.indexOf("WORD");
if (pos >= 0) {
input.setRangeText("*WORD*", pos, pos + 4, "select");
input.focus(); // focus so the selection is visible
}
};
</script>
Example: insert at cursor
When nothing is selected — or when start equals end in setRangeText — the new text is inserted with nothing removed.
This button inserts "MARK" at the cursor and drops the cursor right after it. If a real selection exists, it gets replaced instead (you could branch on selectionStart != selectionEnd to handle that case differently):
<input id="input" style="width:200px" value="Line Line Line Line Line">
<button id="button">Insert "MARK" at cursor</button>
<script>
button.onclick = () => {
input.setRangeText("MARK", input.selectionStart, input.selectionEnd, "end");
input.focus();
};
</script>
Making something unselectable
Three ways to stop content from being selected:
-
The CSS property
user-select: none.<style> #elem { user-select: none; } </style> <div>Selectable <div id="elem">Unselectable</div> Selectable</div>This blocks a selection from starting inside
elem. The user can still start elsewhere and drag across it —elemthen joinsdocument.getSelection(), so the selection does technically include it. But its content is normally left out of copy-paste. -
Prevent the default action in
onselectstartormousedown.<div>Selectable <div id="elem">Unselectable</div> Selectable</div> <script> elem.onselectstart = () => false; </script>Again this only blocks starting the selection on
elem; a user can begin elsewhere and extend into it. This approach shines when another handler is bound to the same action (say amousedowndrag): killing the selection avoids the two fighting, whileelem’s text stays copyable. -
Clear the selection after the fact with
document.getSelection().empty(). Rarely worth it — the selection flashes on before your code wipes it, which looks glitchy.
References
Summary
Two selection APIs, for two situations:
- Document selection — the
SelectionandRangeobjects. <input>and<textarea>— extra text-only properties and methods.
The form-control API is the easy one, since it only ever deals with plain text and character indexes.
The recipes you’ll reach for most:
-
Reading the selection:
let selection = document.getSelection(); let cloned = /* element to clone the selected nodes into */; // apply Range methods to selection.getRangeAt(0), // or loop over all ranges to support multi-select for (let i = 0; i < selection.rangeCount; i++) { cloned.append(selection.getRangeAt(i).cloneContents()); } -
Setting the selection:
let selection = document.getSelection(); // directly: selection.setBaseAndExtent(...from...to...); // or build a range first: selection.removeAllRanges(); selection.addRange(range);
And the cursor: in an editable element like <textarea> the caret always sits at the start or end of the selection. Read elem.selectionStart / elem.selectionEnd to find it, and write to them to move it — set both to the same number to place the caret with nothing selected.