Skip to content
Jace edited this page Jun 6, 2026 · 1 revision

FAQ

Quick answers to the questions developers ask before integrating @highlighters. For the full surface see API-Reference and Options-Reference; for the architecture behind these answers see How-It-Works.

Does it change my DOM?

No, not by default. A mark is painted into a separate overlay container that the library appends as the last child of the host (the document body, or the positioned host you pass). The overlay is absolutely positioned, out of layout flow, aria-hidden, pointer-events: none, user-select: none, and isolation: isolate. Your text nodes are never wrapped, split, or rewritten.

That has two consequences worth stating outright:

  1. A mark cannot cause layout shift, intercept input, or leak into the accessibility tree.
  2. handle.remove() restores the DOM to exactly its pre-highlight state. The overlay is removed and a static host that was promoted to position: relative is the only inline trace; the text itself is untouched throughout.

There is one opt-in exception. Setting semantic: true wraps each targeted run in a real <mark> element so the highlight carries meaning for assistive tech and copy-paste. This is the only mode that mutates your text DOM, and it is fully reversible by remove().

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

// Pure overlay, no DOM mutation (default).
highlight("#intro");

// Opt-in: wrap each run in <mark> for semantics. Reversible by remove().
highlight("#intro", { semantic: true });

See Accessibility-and-Reflow.

Does text selection and find-in-page still work?

Yes. Because the default mode never touches your text, the browser's native text selection, find-in-page (Cmd/Ctrl+F), copy, and screen-reader reading order all keep working exactly as they did before the mark went down. The overlay sits on top with pointer-events: none and user-select: none, so it never gets in the way of a drag or a find hit.

highlightSelection() follows the live selection rather than competing with it: it reads selectionchange and paints over what the user has already selected. See Selection-Highlighting.

Which frameworks are supported?

Core is framework-agnostic vanilla JS. There are first-party bindings for React, Vue, and Svelte, each a thin wrapper over the same highlight() entry point.

Package Surface Requires
@highlighters/core highlight, highlightAll, highlightSelection, group any DOM environment
@highlighters/react Highlight component, useHighlight hook React 18+
@highlighters/vue Highlight component, useHighlight composable Vue 3.3+
@highlighters/svelte highlight action Svelte 3+

Each framework package depends on core, so you install one package, not two. Anything without a binding (Angular, Solid, Lit, Web Components, plain HTML) uses @highlighters/core directly: call highlight(element, options) in your mount lifecycle and handle.remove() on teardown.

// Vanilla, any framework or none.
import { highlight } from "@highlighters/core";
const handle = highlight(el, { color: "#aacfe0" });
// later
handle.remove();
// React
import { Highlight } from "@highlighters/react";
<Highlight options={{ markType: "underline" }}>load-bearing phrase</Highlight>;
<!-- Vue -->
<Highlight :options="{ markType: 'underline' }">load-bearing phrase</Highlight>
<!-- Svelte -->
<p use:highlight={{ markType: "underline" }}>load-bearing phrase</p>

See Which-API-Should-I-Use for choosing between component and hook/composable/action.

Does it work with SSR?

Yes. The render entry points are SSR-safe: outside a DOM they return an inert no-op handle, so calling highlight() during server rendering never throws and never emits markup. The mark is painted on the client once the DOM exists.

The framework bindings handle this for you. The React hook uses useEffect during SSR and useLayoutEffect on the client; the Vue composable runs in onMounted; the Svelte action runs on the client. Nothing renders into the server HTML, so there is no hydration mismatch to manage.

If you need pure geometry, color, or RNG helpers in a server or worker context, import from the DOM-free entry point @highlighters/core/path. It excludes every DOM-touching export (render/*, targeting/*, tier detection) and exposes only the pure builders and types.

import { resolveOptions, buildMarkGeometry } from "@highlighters/core/path";

See SSR-Support.

What is the performance impact?

The design goal is zero idle cost. A mark is computed once, painted once, and then sits there. Specifics:

  • Scroll is free. Marks track their text through CSS positioning on the compositor, with no JS on the scroll path.
  • Reflow is observed, not polled. Each handle owns a reflow observer that repositions only on element/container/window resize, web-font load, and zoom. There is no animation-frame loop.
  • Idempotent style writes. Repaint-prone properties (mask-image, filter) are only re-written when their value actually changes, so a reflow does not flicker an in-flight draw-on.
  • Automatic degrade under load. With renderer: "auto", the SVG tier degrades to the lighter CSS tier when more than 50 marks are visible (DEFAULT_DEGRADE_THRESHOLD), or under prefers-reduced-motion / prefers-reduced-data.
  • One watcher for the page. highlightAll() covers every match with a single debounced MutationObserver rather than one per mark.

WebKit re-rasterizes SVG filters on scroll, so on very dense pages prefer the CSS tier or cap visible marks. See Performance.

Are marks deterministic?

Yes, fully. Every per-mark random value (edge waviness, streak lanes, dry-out gaps, overshoot jitter) derives from a seed, never from Math.random or the wall clock. Identical inputs produce byte-identical marks across scroll, reflow, reload, and between server and client.

The seed is derived from the target's identity when you do not supply one. Pass an explicit seed to pin a mark's exact texture, or to make two different targets share the same look.

highlight("#a", { seed: 42 });
highlight("#b", { seed: 42 }); // identical texture, different text

Because geometry is anchored to an absolute pixel space rather than normalized to the mark's current size, a mark that is already down stays byte-identical as its text grows or wraps. handle.update() re-resolves options without re-seeding, so retuning color or opacity never disturbs the stable geometry. See How-It-Works and Snapping-and-Overshoot.

How big is the bundle?

The packages are zero runtime dependencies, ship both ESM and CJS, bundle their own TypeScript declarations (no @types packages), and are tree-shakeable. If you only call highlight(), a bundler can drop the code paths for highlightAll, highlightSelection, and the renderer tiers you never reach.

For the leanest server or worker footprint, import from @highlighters/core/path, which carries none of the DOM render or targeting code.

How do I theme marks?

Color is set through color, palette, and gradient on HighlightOptions. color is a CSS color string or a palette reference object.

// Any CSS color string: hex, rgb(), hsl(), named, currentColor, var(...).
highlight("#a", { color: "#ffd54a" });
highlight("#b", { color: "var(--brand-ink)" });

// A curated palette family and swatch.
highlight("#c", { color: { palette: "fluorescent", swatch: "pink" } });

// A family's default swatch via the top-level palette field.
highlight("#d", { palette: "mild" });

// A multi-stop gradient (overrides color when present).
highlight("#e", {
  gradient: {
    type: "linear",
    angle: 85,
    stops: [
      { offset: 0, color: "#fff14d" },
      { offset: 1, color: "#ff6fae" },
    ],
  },
});

There are five palette families (fluorescent, mild, vintage, neutral, calm), each an ordered swatch map that doubles as a color-coding cycle. The library default is mild -> yellow (#f5e6a8). Beyond color, the look is tuned through the tip, ink, edge, paper, and glow option groups. See Color-and-Palettes, Ink-and-Optics, and Tips-and-Edges.

Because var(...) is a valid ColorValue, you can drive marks from CSS custom properties and re-theme by changing the variable, then calling handle.update() (or letting framework reactivity push the change).

Heads-up: there is no preset option. Some JSDoc examples in the framework packages show options={{ preset: "wet", color: "pink" }} or use:highlight={{ preset: "mild", color: "gold" }}. Those snippets are illustrative only and do not reflect the real API. HighlightOptions has no preset field, and there is no bare named-swatch shorthand: a string like "pink" is passed straight through as a CSS color, not a palette lookup. To pull from a curated family, use the { palette, swatch } object form shown above. See Color-and-Palettes.

How do I highlight a whole page or the live selection?

Use the two specialized entry points. highlightAll() marks every [data-highlight] element plus a page scan, kept in sync by a MutationObserver. highlightSelection() follows the user's live selection in real time and defers to native selection UI on touch.

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

const page = highlightAll({ palette: "fluorescent" });
const live = highlightSelection({ fadeOnClear: true });

See Page-Highlighting and Selection-Highlighting.

Will it run twice / leak observers?

No. Overlay creation is idempotent per host, and each handle owns its observers (reflow, plus the mutation watcher or selection listener for the page and selection entry points). handle.remove() disconnects all of them and restores the DOM. In a framework, the binding wires remove() into the component lifecycle for you, so unmounting cleans up automatically.

Next steps

Clone this wiki locally