-
-
Notifications
You must be signed in to change notification settings - Fork 3
How It Works
This page explains the internals you need to reason about behaviour: the rendering pipeline, the three renderer tiers and how they auto-degrade, the coordinate space that keeps marks from stretching, and how a mark survives reflow and web-font loads. If you just want to ship, start with Getting-Started; if you are deciding between entry points, see Which-API-Should-I-Use.
Every mark flows through one pipeline with several front doors. Whether you call highlight(), highlightAll(), highlightSelection(), or a framework binding, the same three stages run:
-
Read (targeting, DOM): normalize whatever you passed (element, selector,
Range,Selection, text query, page target) into DOMRanges, then split each range into per-visual-line rectangles. -
Geometry (pure math, DOM-free): turn each line rectangle into an absolute-pixel
MarkGeometry(edges, clip path, noise offsets, pooling), seeded deterministically. - Render (DOM): hand the geometry to the selected renderer tier (SVG / CSS / Highlight API), which paints an overlay above the text.
target ──read──> Range[] ──> LineRect[] ──geometry──> MarkGeometry[] ──render──> overlay
The split matters. Stage 2 is exported from @highlighters/core/path and is fully SSR-safe, so geometry can be computed without a DOM. Stages 1 and 3 touch the DOM and live only in the full @highlighters/core entry. See SSR-Support for the DOM-free subset.
The returned MarkHandle owns everything the pipeline created: the overlay container, the reflow observer, and (for page/selection modes) the mutation watcher or selection listener. remove() tears all of it down and restores the DOM.
A highlight is drawn on top of the text, not into it. createOverlayContainer() builds a single container that is:
- absolutely positioned and out of layout flow, so a mark can never cause layout shift;
-
aria-hiddenandpointer-events: none, so it is invisible to assistive tech and never intercepts clicks or selection; - isolated as its own stacking context.
The container mounts on the document body by default, so its absolute-pixel line boxes line up with the text in document coordinates regardless of the target element's own box. You can scope it to a transformed or scrolling ancestor by passing a host (it is promoted to position: relative if static).
Because the text DOM is untouched, the original markup, its styles, and its selectability are all preserved. The one exception is opt-in: semantic: true wraps each targeted run in a real <mark> element for document semantics, and that wrapping is fully reversed by remove(). See Accessibility-and-Reflow.
toRanges(target) is the single normalizer. An element or selector resolves to its text; a Range or Selection is used directly; a TextTarget ({ text, root }) finds every match within the root, including matches that span inline boundaries; a PageTarget scans a subtree honouring include/exclude (exclusion always wins). If a target resolves to zero ranges, the entry point returns an inert no-op handle.
The resolved ranges are then optionally snapped to word, line, or glyph boundaries before geometry runs (snap: "none" skips this). The default snap depends on the entry point: elements and page scans default to "line", while text queries and live selections default to "word". See Snapping-and-Overshoot.
Finally rangesToLineRects() breaks each range into one rectangle per visual line, so a wrapped paragraph becomes several independent bands.
This is the property that makes a mark look hand-drawn yet never stretch, smear, or swim as it resizes, reflows, or grows. The rule behind it:
Nothing is parameterized against the element's current size. Everything is anchored to an absolute pixel coordinate space, with randomness seeded by position and grid index rather than by a normalized fraction of width.
A naive highlighter normalizes geometry to [0,1] across the mark and scales it to fit. That stretches: a wider mark grows its waviness wavelength, smears its grain, and balloons its rounded cap. Worse, while a mark grows (a dragged selection, a reflow), every vertex is recomputed against the new width, so wobble under the part you already covered visibly shifts. The anchored-grid method computes geometry on a fixed pixel grid and samples textures instead of scaling them, so widening a mark adds more of the same rather than stretching what is there.
-
Texture is a fixed-px tile, repeated and offset, never scaled. The organic grain is
feTurbulencebaked once into a fixed-size tile (stitchTiles="stitch"for a seamless wrap) and used as a CSS mask with a pixelmask-size. Per-line variety comes from sliding the sample window, not resizing the texture. This is the literal answer to "SVGs that don't stretch": render noise into a tile, repeat it, offset the sample. -
Wavy edges live on a fixed grid, seeded by grid index. Edge vertices land at multiples of a constant
segmentLengthpx, and each vertex's jitter is keyed on its grid index, not a fractionk/segs. Growing the mark only appends vertices at fresh grid positions; everything already drawn stays byte-identical. -
Cap pooling is absolute px, clamped. The end darkening that mimics a marker pooling ink is a fixed-px width with
min/maxclamps, so a long mark does not get smeared ends and a very short mark's two pools cannot overrun each other. -
Shape is absolute
path()geometry. The chisel/clip shape is aclip-path: path(...)built in px-local coordinates (corners asQarcs, slant as a px x-shift), not a percentagepolygon(), so radius and slant keep their true size at any width. - Seeds are anchored to a layout-stable identity. Every per-line random value derives from one seed tied to the line's document-stable position (for live selection, its anchor-relative top). That seed is invariant under scrolling and rightward growth, and the left edge is deliberately excluded so growing leftward does not re-roll it. The hash is a pure deterministic function (no wall-clock RNG), so server and client produce identical marks.
- Nodes are pooled by identity, not array index. Overlays are keyed by each line's stable seed, so when the visible line set changes (a mark grows by a line) surviving lines keep their exact node with no re-seed or flicker.
In the pipeline this lives in buildLines(), which projects each line rectangle into the container's local pixel space (subtracting the container's bounding rect ties a line's seed and position to its document position alone, stable under scroll and either drag direction) and calls buildMarkGeometry() per line. An explicit seed option is still hashed per line so each band stays distinct.
Determinism is a hard invariant, not a knob. The configurable parameters (edge.frequency, edge.waviness, edge.radius, tip.angle, and so on) tune the look, but the anchoring rules are non-optional, and the same seed/grid math is carried into all three renderer tiers so degrading fidelity never changes a mark's identity. For the full derivation, see Ink-and-Optics and Tips-and-Edges.
The geometry is paint-able by three renderers. Degradation is fidelity-only: a lower tier never moves or recolours a mark, it only simplifies edge organicness and texture.
Tier (RendererTier) |
Requires | What it draws |
|---|---|---|
"svg" (highest, default) |
clip-path, mask-image, SVG filters (feTurbulence) |
Full organic edges plus baked noise texture. |
"css" (auto-degrade floor) |
mix-blend-mode, box-decoration-break: clone
|
A CSS band with the same color/blend, simplified edges. |
"highlight-api" |
CSS Custom Highlight API (CSS.highlights + Highlight) |
Native ::highlight() painting; registers the Ranges, never wraps the text. |
detectEnvironment() feature-detects support and reads motion/data/pointer preferences, then selectTier() decides:
- Start from the highest supported tier in
svg → css → highlight-apiorder. - If that is
svg, degrade tocsswhen any of:prefers-reduced-motion: reduce,prefers-reduced-data: reduce, or the simultaneously-visible mark count exceeds the threshold (DEFAULT_DEGRADE_THRESHOLD = 50). -
cssis the floor of auto-degrade.highlight-apiis only auto-chosen when it is the highest supported tier.
import { detectEnvironment, selectTier } from "@highlighters/core";
const env = detectEnvironment();
const tier = selectTier("auto", env, /* markCount */ 12); // -> "svg" | "css" | "highlight-api"Set renderer to a concrete tier to pin it. A pinned request is honoured when supported, otherwise the selector steps down to the nearest supported tier (final fallback "css"), so it always yields a concrete tier.
highlight("#summary", { renderer: "css" }); // force the CSS bandRead back what actually rendered via the handle:
const mark = highlight("#summary");
mark.tier; // the concrete RendererTier in useTier choice and its trade-offs are covered in Performance; the Highlight API specifics are in Page-Highlighting and Selection-Highlighting.
A mark is positioned in absolute pixels, so anything that moves the underlying text would otherwise leave the overlay behind. createReflowObserver() funnels every event that can move a mark into one requestAnimationFrame-batched callback:
- a
ResizeObserveron the target and its nearest positioned container (a container resize can move the overlay without resizing the target); - the window
resizeevent; -
document.fonts.ready, so a late web-font load that shifts text metrics triggers exactly one reposition once fonts settle.
When it fires, the mark re-derives geometry and calls renderer.update() without re-animating. An in-flight draw-on is retargeted onto the corrected geometry so it finishes the right shape instead of snapping. Because the anchored-grid seeds are tied to document position, the repositioned mark is byte-identical where it overlaps the old one, so reflow does not re-shuffle the wobble.
For dynamic content, page and declarative modes additionally run a debounced MutationObserver. See the next section and Accessibility-and-Reflow.
highlightAll() marks every [data-highlight] element plus a page scan of document.body (honouring exclusions), then attaches a debounced MutationObserver (50 ms quiet window). Added nodes get marked and removed nodes drop their marks without a full rescan; the watcher's disconnect folds into the handle's remove(). If no matches exist yet, the watcher is still wired so a later DOM change can produce a mark. The attribute is selected purely by presence; there are no documented values for it.
import { highlightAll } from "@highlighters/core";
const marks = highlightAll({ snap: "line" });
// later
marks.remove(); // unmarks everything and disconnects the watcherSee Page-Highlighting for the full declarative workflow.
highlightSelection() drives selectionchange into the same pipeline in real time. A few behaviours are unique to it:
- On coarse pointers (touch) it returns an inert handle and defers to native selection UI, so no overlay is drawn.
- A backward drag (focus before anchor) pours ink from the right edge.
- When the selection empties it fades the overlay out over 200 ms, then drops the bands (gated by
fadeOnClear; instant under reduced-motion). Re-selecting cancels the fade. -
Speed-aware ink (the
speedoption group, Beta) engages here only, and only during a primary-button fine-pointer (mouse/pen) drag. It is suppressed underprefers-reduced-motionand is a byte-identical no-op for programmatic, keyboard, or static selections, or whenspeed.enabledisfalse. -
update()composes additively across calls, andremove()detaches theselectionchangelistener, pointer listeners, and velocity tracker.
See Selection-Highlighting and Animation.
The React/Vue/Svelte packages are thin lifecycle wrappers around the same core entry points. They (re)create a mark when the resolved target or host changes, and push option changes through handle.update() so stable geometry is never re-seeded.
// React
import { Highlight, useHighlight } from "@highlighters/react";
function Note() {
return <Highlight options={{ snap: "word" }}>key idea</Highlight>;
}
function Ref() {
const ref = useRef<HTMLElement>(null);
useHighlight(ref, { snap: "line" });
return <p ref={ref}>highlighted on mount</p>;
}<!-- Vue -->
<script setup>
import { useHighlight } from "@highlighters/vue";
const el = ref(null);
useHighlight(el, { snap: "line" });
</script>
<template><p ref="el">highlighted on mount</p></template><!-- Svelte -->
<script>
import { highlight } from "@highlighters/svelte";
</script>
<p use:highlight={{ snap: "line" }}>highlighted on mount</p>Note: some framework JSDoc examples in source show
presetand bare color names like"pink". There is nopresetoption and no named-color shorthand in the real API.coloris a CSS color string or a{ palette, swatch }object, so a bare"pink"is passed through as a CSS color, not a palette lookup. Use a palette family for coordinated color, e.g.{ color: { palette: "mild", swatch: "pink" } }. See Color-and-Palettes and Options-Reference.
Full binding signatures live in API-Reference.
Because every per-mark random value derives from a position-anchored seed and a pure hash (never a wall-clock PRNG), identical inputs produce byte-identical marks on the server and the client, across reloads, and across renderer tiers. That is what makes SSR/hydration mismatch-free and golden-file testing possible. See SSR-Support and Limitations.
- Which-API-Should-I-Use: choosing an entry point
- Performance: tier costs, the degrade threshold, large-page guidance
- Snapping-and-Overshoot: boundary snapping and edge overshoot
- Tips-and-Edges / Ink-and-Optics: the geometry and ink model in depth
-
Accessibility-and-Reflow: the untouched-text guarantees and
semantic -
SSR-Support: the DOM-free
@highlighters/core/pathsubset
Getting Started
The Mark
- Mark-Types-and-Shapes
- Tips-and-Edges
- Ink-and-Optics
- Color-and-Palettes
- Snapping-and-Overshoot
- Animation
Targeting
Reference
Production