-
-
Notifications
You must be signed in to change notification settings - Fork 3
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.
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-
optionsis a partialHighlightOptions. The defaultsnaphere is"word". - Returns a
MarkHandle. It is wired the moment you call it: it attaches aselectionchangelistener and starts painting on the next selection.
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
snapis"word", so drags lock to whole words. Passsnap: "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-excludeare skipped. The marker paints by range geometry, which still coversuser-select: nonetext, 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.
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".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.
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.)
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(defaulttrue). SetfadeOnClear: falseto drop the mark instantly on clear. - Under
prefers-reduced-motion: reducethe clear is instant regardless, with no fade.
highlightSelection({ fadeOnClear: false }); // instant clear, no 200ms fadeThe 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
},
});Speed dynamics is gated tightly so it never surprises you:
-
Live selection only. It is a byte-identical no-op under
highlight()andhighlightAll(). -
Primary-button fine-pointer drag only. It samples velocity only between
pointerdown(button 0,pointerTypeof"mouse"or"pen") and pointer release. Keyboard selections,Shift-click range extension, and programmaticSelectionchanges 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.enabledisfalse(the default) orsensitivityis0.
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.
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
<= slowSpeeddeposits a full1.0(wettest). - Speed
>= fastSpeeddepositsminDeposit(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.
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 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()istrueonly while there is a non-empty selection (and the handle is shown and not removed). -
remove()is the full teardown: it detaches theselectionchangelistener, 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" });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.
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;
}<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><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.
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.
-
Page-Highlighting:
highlightAllfor whole-page and declarative[data-highlight]marks. -
Which-API-Should-I-Use: picking between
highlight,highlightAll, andhighlightSelection. -
Snapping-and-Overshoot:
snapmodes and end overshoot. - Animation: draw-on direction, easing, stagger, and triggers for non-live marks.
-
Ink-and-Optics: the
inkfields that speed dynamics modulate. - Options-Reference: every option and default.
-
API-Reference: full signatures and the
MarkHandleinterface.
Getting Started
The Mark
- Mark-Types-and-Shapes
- Tips-and-Edges
- Ink-and-Optics
- Color-and-Palettes
- Snapping-and-Overshoot
- Animation
Targeting
Reference
Production