Skip to content

Snapping and Overshoot

Jace edited this page Jun 6, 2026 · 1 revision

Snapping and Overshoot

Two independent stages decide exactly where a mark begins and ends.

  • Snapping (snap) runs first, in DOM space. It nudges the targeted Range start/end to a text boundary (word, line, glyph) or leaves it alone (none). This is about which characters get marked.
  • Overshoot (tip.overshoot, tip.overshootJitter) runs later, in pixel space. It lets each outer end of a band run past (or short of) the snapped text edge, the way a real pen overshoots a word. This is about how far the ink reaches beyond the glyphs.

They compose: snap picks the run, overshoot styles its ends. Both are deterministic.

For the option definitions see Options-Reference; for how line rectangles become bands see How-It-Works.


Snap modes

type SnapMode = "none" | "word" | "line" | "glyph";

Snapping clones the input Range (it never mutates the document) and adjusts its endpoints:

Mode What it does to the range
"none" Range unchanged. The exact start/end you targeted is marked, including any partial words or leading/trailing whitespace.
"word" Trims surrounding whitespace, then expands each end outward to the enclosing word boundary, so a half-selected word becomes whole.
"glyph" Trims surrounding whitespace only. Ends sit flush against the first and last visible glyph.
"line" Trims whitespace like glyph; per-visual-line clamping happens downstream when the range is split into line rectangles.

A word character for the "word" expansion is [\p{L}\p{N}_-] (Unicode letters, numbers, underscore, hyphen). An all-whitespace range that trims to nothing collapses to a paint-nothing range rather than inverting.

Snapping is text-boundary clamping only. It does not move pixels or change band geometry. Overshoot is the pixel-space stage (below).

Defaults are per entry point

There is no single global default. If you omit snap, the value is chosen from the entry point and the target shape:

Entry point Target Default snap
highlight(target) Element or CSS selector string "line"
highlight(target) Range, Selection, or TextTarget ({ text }) "word"
highlightAll() page scan + [data-highlight] "line"
highlightSelection() live selection "word"

So selecting text or marking a phrase snaps to whole words by default, while marking an element or the page snaps to lines. Pass snap explicitly to override.

Choosing a mode

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

// Marking a phrase: keep whole words even if the Range cuts one in half.
highlight({ text: "capillary" }, { snap: "word" });

// Tight to the glyphs, no whitespace, no word growth.
highlight("#term", { snap: "glyph" });

// Mark exactly what was targeted, byte for byte.
highlight(someRange, { snap: "none" });

In a framework, snap is just another option field:

// React
<Highlight options={{ snap: "word" }}>important phrase</Highlight>
<!-- Vue -->
<Highlight :options="{ snap: 'glyph' }">important phrase</Highlight>
<!-- Svelte -->
<span use:highlight={{ snap: "glyph" }}>important phrase</span>

Overshoot

Snapping decides the run; overshoot decides how far past it the ink reaches. After a snapped range is split into one rectangle per visual line, each band's outer ends are extended (or pulled in) in pixels.

interface TipOptions {
  overshoot?: number;       // signed px past the text edge. Default 2.
  overshootJitter?: number; // per-end px variance, >= 0. Default 1.
  // ...
}
  • overshoot is signed. Positive runs the ink past the text edge; negative pulls the band in short of the glyphs.
  • The width box is positive-clamped, so an aggressively negative overshoot can never invert a band to zero or negative width.
  • overshoot and overshootJitter live under tip, alongside the nib type, angle, and angleJitter. See Tips-and-Edges.
// Generous, pen-like overshoot past each end.
highlight("#title", { tip: { overshoot: 6 } });

// Conservative: pull the ink in just shy of the glyphs.
highlight("#title", { tip: { overshoot: -2 } });

Outer ends versus wrapped inner ends

overshoot applies to the outer ends of a run: the start of the first visual line and the end of the last. When a marked phrase wraps across multiple lines, the inner ends (the trailing edge of line 1, the leading edge of line 2, and so on) always overlap regardless of the overshoot value, so a multiline mark reads as one continuous swipe rather than separate disconnected bands.

So on a three-line wrapped phrase:

  • Line 1: outer start uses overshoot; trailing inner end overlaps.
  • Line 2: both ends are inner, both overlap.
  • Line 3: leading inner end overlaps; outer end uses overshoot.

A backward drag under highlightSelection (focus before anchor) pours ink from the right edge, but the outer-versus-inner rule is unchanged.

Jitter

overshootJitter adds a deterministic per-end variance to overshoot. Each end resolves to:

overshoot + hashJitter(endSeed) * overshootJitter

hashJitter is seeded by the width-independent per-line seed, so the left and right ends differ from each other but never change when the mark grows, reflows, or re-renders. The same input always produces the same ends on server and client.

// Hand-drawn: ends land at slightly different distances, stable across renders.
highlight("#title", { tip: { overshoot: 3, overshootJitter: 2 } });

// Perfectly even ends: no per-end variance.
highlight("#title", { tip: { overshoot: 3, overshootJitter: 0 } });

Set overshootJitter: 0 for ruler-straight ends. Determinism is a guarantee of the library: every per-mark random value derives from a seed, never from the wall clock (see How-It-Works).


How ends clamp to boundaries (the full pipeline)

A targeted run reaches its final shape in this order:

  1. Normalize the target to one or more DOM Ranges.
  2. Snap each range with snap (none / word / glyph / line), clamping endpoints to text boundaries. Read-only; the original DOM is untouched.
  3. Split each snapped range into per-visual-line rectangles. "line" snapping resolves to per-line clamping here.
  4. Extend each band's outer ends in pixels by overshoot + jittered overshootJitter; inner (wrapped) ends overlap.
  5. Build the band geometry, edge waviness, and cap from edge (see Tips-and-Edges).

Snapping (step 2) is the only stage that changes which characters are covered. Overshoot (step 4) only changes how far the ink reaches past those characters; it never adds or removes glyphs from the marked run.


Combining snap and overshoot

The two stages are orthogonal, so any pairing is valid.

// Whole words, then a soft pen-overshoot past the first and last word.
highlight({ text: "key insight" }, {
  snap: "word",
  tip: { overshoot: 5, overshootJitter: 2 },
});
// Exactly the targeted characters, ink stopping flush at the glyphs.
highlight(preciseRange, {
  snap: "none",
  tip: { overshoot: 0, overshootJitter: 0 },
});
// React: tight glyph snap with a restrained, even overshoot.
<Highlight options={{ snap: "glyph", tip: { overshoot: 1, overshootJitter: 0 } }}>
  precise term
</Highlight>
<!-- Svelte: live-feeling word snap with jittered ends -->
<p use:highlight={{ snap: "word", tip: { overshoot: 4, overshootJitter: 2 } }}>
  a wrapping paragraph of text
</p>

Changing snap or tip.overshoot later through update() (or a reactive options change in a framework binding) re-resolves the geometry deterministically:

const mark = highlight("#title", { snap: "line" });
mark.update({ snap: "glyph", tip: { overshoot: -1 } });

Related pages

  • Tips-and-Edges - the rest of the tip group (nib type, angle, angle jitter) and the edge group (waviness, cap, radius).
  • Options-Reference - full SnapMode and TipOptions field tables and defaults.
  • How-It-Works - the targeting to line-rect to geometry pipeline, seeds, and determinism.
  • Selection-Highlighting - live highlightSelection behavior, including backward-drag ink direction.
  • Page-Highlighting - highlightAll, the [data-highlight] attribute, and its "line" default.

Clone this wiki locally