-
-
Notifications
You must be signed in to change notification settings - Fork 3
Performance
This page covers what @highlighters costs at runtime, how the three-tier renderer protects your frame rate, where the costs scale with mark count, what happens during scroll and reflow, the Safari/WebKit SVG-filter caveat, and bundle sizes. The goal is to let you decide which API and which tier to use at a given mark density without guessing.
If you have not yet picked an entry point, read Which-API-Should-I-Use first; this page assumes you know whether you are calling highlight, highlightAll, or highlightSelection.
A mark's lifetime breaks into a small number of operations:
-
Targeting. Resolving a
Targetinto ranges and line rects:toRanges,rangesToLineRects, and (for page/text modes)collectPageRanges/findTextRanges. Cost scales with the number of matched ranges and the DOM depth scanned, paid once at mount and again onupdate()only when the target changes. -
Geometry. Pure JS that turns line rects into the mark's edges, clip path, noise tile, and pool gradient (
buildMarkGeometry,buildEdge,buildClipPath,buildNoiseTile,buildPoolGradient). Deterministic from a seed, independent of any other mark. One pass per visual line. - Render. Mounting the chosen tier's nodes and applying styles. This is the only step whose cost depends on the tier (see below).
- Reflow. Repositioning when something moves the text. A single rAF-batched callback per handle (see Scroll and reflow).
The dominant driver of cost is the renderer tier and the number of visual lines (not logical marks). A three-line wrapped highlight builds three bands; a one-word selection builds one. Idle cost is zero in every tier: once a mark is down and nothing reflows, no JS runs.
Determinism is what makes growth cheap. Every per-mark random value derives from a seed (no PRNG seeded by wall-clock), and geometry is anchored to an absolute pixel grid, so extending a mark adds new detail without re-seeding the region already drawn. update() re-resolves options and re-renders without re-seeding stable geometry, and reflow repositions without re-running filters.
Three renderers sit behind one API. They share color, opacity, blend mode, and band position, so dropping a tier changes only edge organicness and texture fidelity, never where a mark sits or what color it is. Full type details are in How-It-Works and the tier table in API-Reference.
Tier (RendererTier) |
Cost profile | Requires |
|---|---|---|
"svg" (default, highest fidelity) |
Heaviest. One shared <svg>/<defs> filter block, clip-path + mask-image per band, an SVG turbulence filter. |
clip-path, mask-image, SVGFETurbulenceElement
|
"css" (auto-degrade floor) |
Light. A CSS gradient band with mix-blend-mode and box-decoration-break: clone. No SVG filter, no mask decode. |
mix-blend-mode, box-decoration-break: clone
|
"highlight-api" |
Lightest. Native ::highlight() painting via the CSS Custom Highlight API; the browser paints it. |
CSS.highlights + Highlight
|
The SVG tier amortizes its filter cost: marks whose parameters quantize to the same bucket share one interned filter in a single document-level <defs>, so a page of similar marks does not allocate a filter per mark.
With the default renderer: "auto", the selector starts from the highest supported tier (svg → css → highlight-api order) and then applies degrade rules. If the chosen tier is svg, it steps down to css when any of these hold:
prefers-reduced-motion: reduceprefers-reduced-data: reduce- mark count is greater than
50(DEFAULT_DEGRADE_THRESHOLD)
css is the floor of auto-degrade. highlight-api is auto-chosen only when it is the highest supported tier; otherwise it is reached only by pinning. Pinning (renderer set to a concrete tier) honours the request when supported, else steps down to the nearest supported tier, always yielding a concrete tier (final fallback "css").
import { highlight } from "@highlighters/core";
// Let the library protect the frame rate (default).
highlight("#intro");
// Pin the lightweight CSS band, e.g. for a dense list you control.
highlight("#intro", { renderer: "css" });The selected tier is observable per mark via handle.tier:
const handle = highlight("#intro");
console.log(handle.tier); // "svg" | "css" | "highlight-api"For highlightSelection, the tier getter returns the live renderer's tier, or "css" before the first mount.
You can inspect the environment and run the selector yourself with the exported detectEnvironment and selectTier (both SSR-safe; detectEnvironment reads feature support and motion/data/pointer preferences):
import { detectEnvironment, selectTier, DEFAULT_DEGRADE_THRESHOLD } from "@highlighters/core";
const env = detectEnvironment();
const tier = selectTier("auto", env, /* markCount */ 120);
// tier === "css" here: 120 > DEFAULT_DEGRADE_THRESHOLD (50) degrades svg to css.The 50 threshold is the count of simultaneously visible marks above which auto drops the SVG tier to the CSS band. This is the single most important number for high-density usage: below it you get full organic fidelity; above it the library trades texture for throughput automatically, no code change required.
Practical guidance by density:
- Under ~50 marks. Use the defaults. The SVG tier renders full-fidelity ink and idle cost is zero.
-
Around or above 50 marks.
autoalready degrades to CSS. If you want full SVG fidelity above the threshold and accept the cost, that is not exposed as a higher threshold; instead pin per mark and measure. If you want predictable lightness, pinrenderer: "css"so every mark uses the band regardless of count. -
Hundreds to thousands of marks. Prefer
renderer: "css"or"highlight-api", and virtualize the surrounding list so only visible rows are highlighted. WithhighlightAll, off-screen content still produces marks; combine withcontent-visibility: autoon row containers so the browser skips painting off-screen bands (the bands still exist in the DOM, they just are not painted until scrolled near).
A note on what the count is measured against: selectTier takes markCount as an argument. The library passes the live count of simultaneously visible marks, so a page that reveals marks progressively can cross the threshold as more appear.
Scroll costs no JavaScript. Overlays are absolutely positioned and move with the document via the compositor. The SVG tier's filters are computed once per geometry and reused until reflow, so scrolling never re-filters. There is no scroll listener; idle and scrolling-only frames run zero @highlighters code.
Reflow is rAF-batched. A single createReflowObserver per handle funnels every event that can move a mark into one requestAnimationFrame callback:
-
ResizeObserveron each target and its nearest positioned ancestor (a container resize moves overlays even when the target itself does not resize) -
windowresize -
document.fonts.ready(a late web-font load shifts text metrics, so the mark repositions once fonts are in)
Reflow repositions instantly; it does not re-animate the draw-on and does not re-seed geometry. A burst of resize events collapses into one callback per frame. The observer is owned by the handle and fully disconnected by remove().
Dynamic DOM (page/declarative modes). highlightAll attaches a debounced MutationObserver (createMutationWatcher, 50ms debounce) rooted at the scan root. Added nodes get marked and removed nodes drop their marks without a full rescan; a mutation burst flushes once the DOM is quiet. The watcher's disconnect folds into the returned handle's remove(). This is the one mode with an always-on observer; highlight on a static element has no mutation watcher.
If you mount inside a transformed, scrolling, or stacked container, pass host so overlays position against that element instead of the body:
const panel = document.querySelector("#scroller");
highlight("#intro", {}, panel); // overlay mounts inside #scrollerhost is promoted to position: relative if it is currently static. See Accessibility-and-Reflow for the reflow guarantees in full and Page-Highlighting for the mutation-watcher behavior.
WebKit (desktop Safari and every iOS browser, since they all use WebKit) cannot rasterize SVG turbulence/filter chains at the speed Chromium and Firefox do, and re-rasterizes filter regions more eagerly on invalidation. For the SVG tier this matters most when many filtered marks animate or move together: each invalidation can re-run the filter region, and the per-frame cost piles up.
Two things keep this from biting in normal use:
-
Filters are never animated. The SVG tier computes each filter once per geometry and references it; the draw-on entrance wipes a
clip-path: inset(...)open rather than re-running the filter. Scrolling and the entrance animation do not re-filter. -
Filters are interned and shared. One document-level
<defs>holds the filter set; marks that quantize to the same parameters reference the same filter, so a page of similar marks is not a page of filters.
Where you still want to act:
-
High mark density on Safari. The
autodegrade to the CSS band above 50 marks already removes SVG filters from the hot path on every engine, Safari included. If you are pinningsvgand targeting Safari at density, reconsider and pincss. -
Heavy filtered UI alongside your marks. If a Safari page is already filter-bound (your own
feGaussianBlur/backdrop-filterchrome), preferrenderer: "css"for the highlights so they do not add to the filter budget. -
Off-screen marks. Pair
highlightAllor long lists withcontent-visibility: autoon row containers so WebKit does not pay for painting bands you cannot see.
This is not specific to @highlighters. Any library applying SVG filters to many on-screen elements pays the same WebKit cost; the mitigations above are the standard ones. See Limitations for the broader trade-off list and Ink-and-Optics for which options actually invoke the SVG filter (texture, feathering, glow) versus which are pure geometry.
Zero runtime dependencies. ESM + CJS dual export, sideEffects: false, fully tree-shakeable, so importing one function does not pull in unused renderer tiers or framework wrappers.
| Import | Brotli | Gzip | Notes |
|---|---|---|---|
@highlighters/core (full) |
~13 KB | ~14.5 KB | All entry points, all three tiers, targeting, geometry, palettes. |
@highlighters/core/path (DOM-free) |
well under 1 KB | well under 1 KB | Re-export stub over the shared chunk; tree-shakes to only the pure builders you import. |
@highlighters/react / vue / svelte (binding only) |
well under 1 KB | well under 1 KB | A thin wrapper. The real installed cost is the wrapper plus its tree-shaken slice of core, landing around 12-15 KB brotli depending on which entry points and tiers your usage reaches. |
The headline figures: core is roughly 14 KB, and a framework binding plus the core it pulls in lands around 12-15 KB brotli. Because the package is tree-shakeable, the upper end of that range is the full surface; a consumer that uses only one tier and one entry point trims toward the lower end. The bindings themselves are tiny because all the work lives in core, which each binding lists as a regular dependency (the framework is a peer dependency and is never bundled).
To shrink further:
- Import only the entry points you use. The bindings re-export a convenience subset of core types but only the runtime you reference is bundled.
- Use
@highlighters/core/pathin SSR/worker code paths that only need pure geometry, config, and RNG. It excludes allrender/*andtargeting/*DOM code. See SSR-Support.
// Vanilla: pulls only the render entry point you call.
import { highlight } from "@highlighters/core";// React: the binding is a thin wrapper over core.
import { Highlight, useHighlight } from "@highlighters/react";// Vue
import { Highlight, useHighlight } from "@highlighters/vue";<!-- Svelte -->
<script>
import { highlight } from "@highlighters/svelte";
</script>
<p use:highlight={{ renderer: "css" }}>...</p>-
Trust
autofirst. It already degrades SVG to CSS under reduced-motion, reduced-data, and above 50 marks. Most apps never need to pin a tier. -
Pin
cssfor known-dense surfaces. A long highlighted list or a comment thread with hundreds of marks is more predictable on the CSS band than on the SVG tier riding the degrade threshold. -
Virtualize above a few hundred marks. Only highlight visible rows. Pair
highlightAllwithcontent-visibility: autoon row containers so off-screen bands are not painted. - Animate fewer SVG-filtered marks at once on Safari. The entrance draw-on does not re-filter, but if you move many filtered marks simultaneously, prefer the CSS band there.
-
remove()is real teardown. It restores the DOM, disconnects the reflow observer (and, forhighlightAll, the mutation watcher). Call it when a mark's content unmounts so observers do not linger. -
update()is cheap relative to mount when the target is unchanged: it re-resolves options and re-renders without re-seeding geometry. Pushing option changes through one handle beats removing and re-creating.
-
Which-API-Should-I-Use for choosing
highlightvshighlightAllvshighlightSelection. - How-It-Works for the tier internals and the anchored-grid geometry that makes growth cheap.
-
Accessibility-and-Reflow for the full reflow and
prefers-reduced-motionguarantees. - Limitations for the WebKit filter trade-off in context.
-
SSR-Support for
@highlighters/core/pathand deterministic server rendering. -
Animation for draw-on cost and the
in-viewIntersectionObservertrigger.
Getting Started
The Mark
- Mark-Types-and-Shapes
- Tips-and-Edges
- Ink-and-Optics
- Color-and-Palettes
- Snapping-and-Overshoot
- Animation
Targeting
Reference
Production