Skip to content

Selection Highlighting

Jace edited this page Jun 10, 2026 · 2 revisions

Selection Highlighting

highlightSelection paints the user's live selection in real time. As the caret drags, ink follows it across the page, snapped to word boundaries, and fades out when the selection clears. It is the one entry point that engages speed-aware ink: a faster swipe lays down a drier, lighter mark.

This page covers the API, the direction and velocity nuance, and how to wire it into vanilla, React, Vue, and Svelte. For the page-wide / declarative counterpart, see Page-Highlighting. For choosing between entry points, see Which-API-Should-I-Use.

At a glance

import { highlightSelection } from "@highlighters/core";

const handle = highlightSelection({ palette: "fluorescent" });
// ... later
handle.remove();

highlightSelection lives in @highlighters/core. It is not a framework component or hook: there is no <Highlight>-style binding for the live selection. You call the core function from whatever lifecycle your framework gives you (see Framework usage).

function highlightSelection(options?: HighlightOptions): MarkHandle
  • options is a partial HighlightOptions. The default snap here is "word".
  • Returns a MarkHandle. It is wired the moment you call it: it attaches a selectionchange listener and starts painting on the next selection.

What it does

Internally it listens to the document's selectionchange event. On every change it reads document.getSelection(), snaps the ranges (default "word"), and updates one overlay in place. There is a single renderer mounted for the lifetime of the handle; selecting, extending, and re-selecting all reuse it rather than tearing down and rebuilding.

  • Default snap is "word", so drags lock to whole words. Pass snap: "none" to paint the raw character range, or "line" / "glyph" for the other modes. See Snapping-and-Overshoot.
  • The overlay never touches your text: selection, copy, and find-in-page keep working. See Accessibility-and-Reflow.
  • Subtrees marked data-highlight-exclude are skipped. The marker paints by range geometry, which still covers user-select: none text, so a select-all (Cmd/Ctrl+A) would otherwise band over read-only regions; the attribute carves them out. It is the same opt-out the page scans use, see Page-Highlighting.
  • Determinism still holds. Per-mark randomness derives from a seed, not wall-clock, so the band's frayed edge is stable across repaints of the same selection.

Touch devices opt out

On a coarse pointer (touch), highlightSelection returns an inert no-op handle and paints nothing. Native selection UI is the better experience there, and a drawn overlay would fight the OS selection handles. The check is detectEnvironment().coarsePointer; the handle's methods are all safe no-ops.

const handle = highlightSelection(opts);
// On touch this is inert: show()/hide()/update()/remove() do nothing, tier reads "css".

SSR

With no DOM, highlightSelection returns an inert handle and registers no listeners. It is safe to call during SSR or in a non-browser runtime; nothing is painted until it runs on the client. See SSR-Support.

Direction nuance: which edge the ink pours from

The draw-on follows the drag direction, not just left-to-right.

  • A forward drag (anchor before focus) pours ink from the left edge, the normal reading direction.
  • A backward drag (focus before the anchor, dragging right-to-left or up the page) pours ink from the right edge, so the mark grows toward the caret the way a real pen would.

This is detected per repaint from the selection's anchor/focus order. Collapsed or detached selections read as forward. You do not configure it; it tracks the gesture automatically. (This is distinct from animation.direction, which sets a fixed sweep direction for non-live marks. See Animation.)

Clear-fade

When the selection empties, the overlay does not vanish abruptly. It fades out over 200ms and then drops its bands. Re-selecting during the fade cancels it and the mark stays live.

  • Gated by fadeOnClear (default true). Set fadeOnClear: false to drop the mark instantly on clear.
  • Under prefers-reduced-motion: reduce the clear is instant regardless, with no fade.
highlightSelection({ fadeOnClear: false }); // instant clear, no 200ms fade

Velocity nuance: speed-aware ink (Beta)

The speed option group is the headline feature of live selection, and it engages only here. The idea: a slow, deliberate swipe deposits wet, saturated ink; a fast swipe runs dry and light, the way a highlighter does when you whip it across a line.

It is off by default. Turn it on with speed.enabled:

highlightSelection({
  speed: {
    enabled: true,
    sensitivity: 1,     // overall strength (0 disables)
    slowSpeed: 2.5,     // px/ms at/below which ink is wettest
    fastSpeed: 10.5,    // px/ms at/above which ink is driest
    minDeposit: 0.4,    // legibility floor: the fastest swipe still deposits this fraction
  },
});

When it actually runs

Speed dynamics is gated tightly so it never surprises you:

  • Live selection only. It is a byte-identical no-op under highlight() and highlightAll().
  • Primary-button fine-pointer drag only. It samples velocity only between pointerdown (button 0, pointerType of "mouse" or "pen") and pointer release. Keyboard selections, Shift-click range extension, and programmatic Selection changes build no velocity field and paint at full deposit.
  • Suppressed under prefers-reduced-motion: reduce. No tracker is created at all.
  • No-op when speed.enabled is false (the default) or sensitivity is 0.

The look persists after you release: nothing repaints until the next gesture, so the dried-out mark you drew stays as drawn. A subsequent non-drag selection paints fresh at full deposit, because the tracker only feeds while dragging is true.

How speed maps to ink

The tracker samples the focus caret position over time, smooths the speed with an EMA (smoothing), and builds a per-line deposit profile in the same pixel space the bands are drawn in. For each point along a line:

  • Speed <= slowSpeed deposits a full 1.0 (wettest).
  • Speed >= fastSpeed deposits minDeposit (driest, but never below the legibility floor).
  • In between, deposit falls linearly, scaled by sensitivity.

A fast swipe additionally nudges the texture toward "dry": more skipping (dryoutBoost), more lengthwise streaking (streakBoost), and a sharper edge (featherReduce). Decelerating into the end of a line pools ink there (poolBoost), mimicking a pen slowing to a stop.

Speed fields

All clamped during resolution; fastSpeed is forced >= slowSpeed.

Field Type Default Range Meaning
enabled boolean false Master enable.
sensitivity number 1 0-1 Overall strength (0 disables).
slowSpeed number 2.5 px/ms, >= 0 At/below this speed, ink is wettest.
fastSpeed number 10.5 px/ms, >= 0 At/above this speed, ink is driest.
minDeposit number 0.4 0-1 Legibility floor for the fastest swipe.
smoothing number 1 0-1 Velocity EMA weight on the newest sample.
resolution number 24 4-24 Core gradient stops per line.
dryoutBoost number 1 0-1 How much a fast swipe adds skipping.
streakBoost number 0.08 0-1 How much a fast swipe adds streakiness.
featherReduce number 1 0-1 How much a fast swipe sharpens the edge.
poolBoost number 1 0-1 How much deceleration into a line end pools ink.

See Ink-and-Optics for the underlying ink fields these dynamics modulate.

The handle

The returned MarkHandle behaves as everywhere else, with a couple of live-selection specifics:

const handle = highlightSelection({ speed: { enabled: true } });

handle.hide();   // hide the overlay without detaching listeners
handle.show();   // reveal it again
handle.tier;     // live renderer tier, or "css" before the first paint
handle.remove(); // detach selectionchange + pointer listeners, drop the tracker, restore the DOM
  • update(opts) composes additively. Each call merges over the accumulated options via field-wise merge, so options from earlier calls are preserved rather than reset. Geometry is re-resolved without re-seeding.
  • isShowing() is true only while there is a non-empty selection (and the handle is shown and not removed).
  • remove() is the full teardown: it detaches the selectionchange listener, the pointer listeners, and the velocity tracker. Always call it on cleanup.
// Additive update: turn speed on now, change palette later, both stick.
handle.update({ speed: { enabled: true } });
handle.update({ palette: "vintage" });

Framework usage

highlightSelection is document-global: it tracks whatever the user selects anywhere, so it is not tied to a single element. Set it up once in a mount effect and tear it down on unmount.

React

import { useEffect } from "react";
import { highlightSelection } from "@highlighters/core";

function SelectionInk() {
  useEffect(() => {
    const handle = highlightSelection({
      palette: "fluorescent",
      speed: { enabled: true },
    });
    return () => handle.remove();
  }, []);

  return null;
}

Vue

<script setup>
import { onMounted, onBeforeUnmount } from "vue";
import { highlightSelection } from "@highlighters/core";

let handle = null;

onMounted(() => {
  handle = highlightSelection({ palette: "fluorescent", speed: { enabled: true } });
});

onBeforeUnmount(() => {
  handle?.remove();
});
</script>

Svelte

<script>
  import { onMount } from "svelte";
  import { highlightSelection } from "@highlighters/core";

  onMount(() => {
    const handle = highlightSelection({ palette: "fluorescent", speed: { enabled: true } });
    return () => handle.remove();
  });
</script>

In every case you import from @highlighters/core, not from the framework package. The framework packages (@highlighters/react, @highlighters/vue, @highlighters/svelte) bind highlight() to elements; they do not wrap the live selection.

Accuracy note

Some framework JSDoc examples in the source show option snippets like options={{ preset: "wet", color: "pink" }} or use:highlight={{ preset: "mild", color: "gold" }}. There is no preset option in the API, and no named-color shorthand. HighlightOptions has no preset field. color is a CSS color string or a { palette, swatch } object, so a bare "pink" would be passed straight through as a CSS color, not resolved against a palette. To use a palette swatch, pass { palette: "fluorescent" } or { color: { palette: "fluorescent", swatch: "pink" } }. See Color-and-Palettes.

Related

Clone this wiki locally