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:

Cloud012345
Boundary offsets in a text node sit between characters. Start 2, end 4 captures 'ou'.

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:

P (element)
├─ #text “Menu: “ ← index 0
├─ I (element) ← index 1
└─ #text “soup”
├─ #text “ and “ ← index 2
└─ B (element) ← index 3
└─ #text “cake”
DOM tree of the paragraph. The <p> element has four direct children at indexes 0–3.

Say we want a range for Menu: <i>soup</i>. That’s exactly the first two children of <p>, indexes 0 and 1:

0
#text
“Menu: “
1
<i>
soup
2
#text
” and “
3
<b>
cake
4
Child-offset positions inside <p>. Offsets sit between children. Start 0, end 2 selects children 0 and 1.
  • The start point uses <p> as its node and 0 as the offset → range.setStart(p, 0).
  • The end point also uses <p>, but offset 2. The end offset is exclusive — the range runs up to but not including child index 2range.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:

interactiveRange by child index — pick a start and end

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:

0
#text
“Menu: “
1
<i>
soup
2
#text
” and “
3
<b>
cake
4
setStart(p, 1) to setEnd(p, 4): children 1, 2, 3 are inside the range.

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:

Menu: soup and cake
start ↑ text offset 2 in “Menu: “    end ↑ text offset 3 in “cake”
A mixed range: text-node offset 2 in <p>'s first child to text-node offset 3 in <b>'s child.

The boundaries live at the text level, so both use text nodes:

  • start at position 2 in <p>’s first child — skip Me, keep the rest of Menu:
  • end at position 3 in <b>’s first child — keep cak of cake, drop the trailing e
<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:

startContainer
first text node in <p>
startOffset
2
endContainer
first text node in <b>
endOffset
3
collapsed
false
commonAncestorContainer
<p>
Range boundary properties for the setStart(p.firstChild, 2) → setEnd(b.firstChild, 3) example.
  • startContainer, startOffset — the node and offset of the start. Here, <p>’s first text node and 2.
  • endContainer, endOffset — the node and offset of the end. Here, <b>’s first text node and 3.
  • collapsed — a boolean, true when start and end sit at the exact same point, meaning the range holds nothing. Here it’s false.
  • 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 position offset inside node
  • setStartBefore(node) — start immediately before node
  • setStartAfter(node) — start immediately after node

Set the end, with matching variants:

  • setEnd(node, offset) — end at position offset inside node
  • setEndBefore(node) — end immediately before node
  • setEndAfter(node) — end immediately after node

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 wrap node itself (including its tags)
  • selectNodeContents(node) — make the range wrap everything inside node, tags excluded
  • collapse(toStart) — squash the range to a single point: toStart=true moves the end onto the start, otherwise the start onto the end
  • cloneRange() — 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 document
  • extractContents() — remove it from the document and hand it back as a DocumentFragment
  • cloneContents() — copy the content and return it as a DocumentFragment, leaving the document untouched
  • insertNode(node) — insert node at the start of the range
  • surroundContents(node) — wrap node around 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:

interactiveRange editing methods — run each one, then reset

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.

3 ranges → rangeCount === 3

A Firefox selection can hold several separate ranges at once.

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 from 0. Everywhere except Firefox, only 0 ever 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 starts
  • anchorOffset — the offset inside anchorNode where it starts
  • focusNode — the node where the selection ends
  • focusOffset — the offset inside focusNode where it ends
  • isCollapsedtrue if the selection is empty (or absent)
  • rangeCount — how many ranges are in the selection; at most 1 outside Firefox

Selection events

Two events let you follow what’s being selected:

  • elem.onselectstart — fires when a selection begins on elem (or inside it), for example the moment the user presses the mouse on it and starts to drag.
    • Calling preventDefault() (or returning false) 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.
  • document.onselectionchange — fires whenever the selection changes or starts.
    • This one only works on document. It watches every selection in the page.

Tracking the selection

A small demo that reads the live selection off document and displays its two boundaries:

interactiveWatch the selection's anchor and focus

Copying the selection

There are two ways to copy what’s selected:

  1. document.getSelection().toString() gives you plain text.
  2. To keep the full DOM — formatting and all — pull the underlying ranges with getRangeAt(...). Each Range has cloneContents(), which copies its content into a DocumentFragment you can drop anywhere.

This demo copies the selection both ways at once:

interactiveCopy a selection two ways — formatted vs plain text

Selection methods

You can edit a selection by adding and removing ranges:

  • getRangeAt(i) — get the i-th range, from 0. Only 0 matters outside Firefox.
  • addRange(range) — add range. Every browser except Firefox ignores this call if the selection already has a range.
  • removeRange(range) — remove range.
  • removeAllRanges() — remove every range.
  • empty() — an alias for removeAllRanges.

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 at node / offset
  • setPosition(node, offset) — alias for collapse
  • collapseToStart() — collapse to the current selection’s start
  • collapseToEnd() — collapse to the current selection’s end
  • extend(node, offset) — move the focus to node / offset, leaving the anchor put
  • setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) — replace the selection outright with the given start and end; everything between them becomes selected
  • selectAllChildren(node) — select all children of node
  • deleteFromDocument() — remove the selected content from the document
  • containsNode(node, allowPartialContainment = false) — test whether the selection contains node (partial matches count when the second argument is true)

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 (textarea too)

  • input.setSelectionRange(start, end, [direction]) — select from start to end, optionally in a given direction

  • input.setRangeText(replacement, [start], [end], [selectionMode]) — replace a stretch of text with replacement.

    If you pass start and end, 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:

Fresh breadselectionStart6selectionEnd11
In an input, selectionStart and selectionEnd are plain character indexes into the value.

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:

  • onselect fires when text becomes selected, but not when the selection is cleared.
  • document.onselectionchange should 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 *...*:

interactiveWrap the selected text in *stars* with setRangeText

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:

  1. 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 — elem then joins document.getSelection(), so the selection does technically include it. But its content is normally left out of copy-paste.

  2. Prevent the default action in onselectstart or mousedown.

    <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 a mousedown drag): killing the selection avoids the two fighting, while elem’s text stays copyable.

  3. 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:

  1. Document selection — the Selection and Range objects.
  2. <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:

  1. 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());
    }
  2. 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.