Skip to content

Page Highlighting

Jace edited this page Jun 10, 2026 · 2 revisions

Page Highlighting

This guide covers marking whole pages, regions, and text queries with @highlighters: highlightAll for declarative/page-wide marking, include/exclude selectors for scoping, text-query targeting for matching specific strings, and surgical exclusions.

For live, user-driven selection marking see Selection-Highlighting. For a tour of every entry point see Which-API-Should-I-Use.


highlightAll — mark the whole page

highlightAll(options?) scans document.body, marks every visible text run it finds, and also marks every element carrying a data-highlight attribute. One call, one handle, the whole page.

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

const handle = highlightAll();
// ...later
handle.remove(); // restore the original DOM

It returns a single API-Reference covering all matches. The default snap is "line" (whole-line bands rather than per-word). To recolour or restyle, pass options exactly as you would to highlight:

const handle = highlightAll({
  color: { palette: "fluorescent", swatch: "yellow" },
  opacity: 0.4,
});

Live syncing

highlightAll attaches a debounced MutationObserver to the root. Nodes added later get marked; nodes removed drop their marks, with no full rescan. The watcher's teardown folds into the handle, so handle.remove() disconnects it.

This means highlightAll is safe to call before content has loaded. If there are no matches yet, it still wires the watcher and returns a live handle, so a later DOM change produces marks.

Outside a DOM (SSR), it returns an inert no-op handle. See SSR-Support.

Performance: a page-wide mark can produce a large mark count. Above the auto-degrade threshold (50 simultaneous marks) the SVG tier steps down to the CSS tier. See Performance and How-It-Works.


Scoping with PageTarget

Page-wide marking does not have to mean the entire body. Pass a PageTarget to highlight() to scan a sub-tree with include/exclude selectors:

interface PageTarget {
  root?: Element | Document; // Root to scan. Defaults to document.body.
  include?: string[];        // Selectors whose subtrees are additionally included.
  exclude?: string[];        // Selectors whose subtrees are excluded.
}
import { highlight } from "@highlighters/core";

highlight({
  root: document.querySelector("article")!,
  exclude: ["pre", "code", "figure"],
});

A few rules worth knowing:

  • No include means everything. When include is empty (or omitted), the whole root is in scope. Add selectors to narrow scope to only those subtrees.
  • Exclusion always wins. A node is dropped the moment any ancestor matches an exclude selector, even if it also matches an include. Exclude has precedence at every level.
  • Invalid selectors are inert. A malformed selector in include or exclude matches nothing rather than throwing.
  • Non-rendered text is skipped automatically. Text inside <script>, <style>, <head>, and similar non-rendered subtrees is never marked, so you do not need to exclude them yourself.
  • Whitespace is trimmed. Each accepted text node is marked over its first-to-last non-whitespace span, so bands hug the visible glyphs.

Include only specific regions

highlight({
  root: document.body,
  include: [".prose", "[data-mark]"],
  exclude: [".prose blockquote"],
});

Here only text inside .prose or [data-mark] is in scope, and any blockquote within .prose is carved back out.


Surgical exclusions

There are two ways to keep a region from being marked, and both apply to highlightAll and to any PageTarget scan.

1. The exclude selector array

Selector-based, defined at the call site:

highlight({ root: document.body, exclude: ["nav", "footer", ".no-highlight"] });

2. The data-highlight-exclude attribute

Declarative, defined in the markup. Any element carrying data-highlight-exclude has its entire subtree skipped by highlightAll, PageTarget scans, and the live highlightSelection marker, with no call-site configuration:

<article>
  <p>This paragraph is highlighted.</p>
  <aside data-highlight-exclude>
    <p>Nothing in here gets marked.</p>
  </aside>
</article>

This is the inverse of the data-highlight opt-in used by highlightAll. Use data-highlight to force a single element to be marked; use data-highlight-exclude to carve a subtree out of any page scan or the live selection marker.

data-highlight-exclude is honoured by both the page-scan walker and the live-selection marker; data-highlight (the opt-in) applies to page scans only. Both are presence-based: there are no meaningful values, they are matched as [data-highlight] and [data-highlight-exclude].


Text-query targeting

To mark every occurrence of a string or pattern, pass a TextTarget to highlight():

interface TextTarget {
  text: string | RegExp;     // Every match within root.
  root?: Element | Document; // Defaults to document.body.
}
import { highlight } from "@highlighters/core";

// Literal string: case-sensitive, non-overlapping.
highlight({ text: "TypeScript" });

// RegExp: scanned globally; case follows the `i` flag.
highlight({ text: /\b(perf|performance)\b/i, root: document.querySelector("main")! });

Matching behaviour, taken from source:

  • String queries are literal, case-sensitive, and non-overlapping. The scan advances past each match.
  • RegExp queries are scanned globally over the concatenated text. Case follows the i flag. Zero-width matches are skipped (no infinite loops). A sticky y flag is stripped so the scan does not stop at the first gap.
  • Matches can span inline boundaries. The walk concatenates text nodes into one flat string, so a query matches across inline wrappers like foo<em>bar</em>baz.
  • Text inside non-rendered subtrees (<script>, <style>, etc.) is never matched.

The default snap for a text query is "word". See Snapping-and-Overshoot for how snap rounds ranges to word, line, or glyph boundaries.

Targeting one element's text

A plain Element or a CSS selector string also resolves through the same pipeline. A selector marks every matching element's text content:

highlight(document.querySelector("h1")!); // one element
highlight("h2, h3");                        // every matching element

For elements and selectors the default snap is "line".


Low-level targeting helpers

If you need the resolved Ranges without rendering (for measuring, custom rendering, or your own observers), the same functions that power the entry points are exported from @highlighters/core:

import { findTextRanges, collectPageRanges } from "@highlighters/core";

// Range per match of a string or RegExp under a root.
const ranges = findTextRanges(document.body, "TypeScript");

// Range per visible text run under a root, honouring include/exclude.
const pageRanges = collectPageRanges({
  root: document.body,
  exclude: [".sidebar"],
});

Both are DOM-touching, so they live in the full @highlighters/core entry, not the SSR-safe @highlighters/core/path subset. Both never throw and return [] when there is no DOM or no match. See API-Reference for the complete export list.


Framework usage

The framework bindings are built around marking a single element via a ref, so they map most naturally to data-highlight / data-highlight-exclude declarative scanning. For page-wide and text-query marking, call the core highlightAll / highlight functions from an effect and tear the handle down on cleanup.

React

import { useEffect } from "react";
import { highlightAll } from "@highlighters/core";

function usePageHighlights() {
  useEffect(() => {
    const handle = highlightAll({ color: { palette: "mild", swatch: "yellow" } });
    return () => handle.remove();
  }, []);
}

Text-query marking scoped to a container:

import { useEffect, useRef } from "react";
import { highlight } from "@highlighters/core";

function SearchHits({ query }: { query: string }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!ref.current || !query) return;
    const handle = highlight({ text: query, root: ref.current });
    return () => handle.remove();
  }, [query]);
  return <div ref={ref}>/* content */</div>;
}

For marking a single element by ref, prefer the useHighlight hook or <Highlight> component, documented in API-Reference.

Vue

<script setup lang="ts">
import { onMounted, onBeforeUnmount } from "vue";
import { highlightAll, type MarkHandle } from "@highlighters/core";

let handle: MarkHandle | null = null;
onMounted(() => {
  handle = highlightAll({ opacity: 0.4 });
});
onBeforeUnmount(() => handle?.remove());
</script>

Text query scoped to a template ref:

<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from "vue";
import { highlight, type MarkHandle } from "@highlighters/core";

const root = ref<HTMLElement | null>(null);
const props = defineProps<{ query: string }>();
let handle: MarkHandle | null = null;

watch(
  () => props.query,
  (q) => {
    handle?.remove();
    if (root.value && q) handle = highlight({ text: q, root: root.value });
  },
);
onBeforeUnmount(() => handle?.remove());
</script>

<template>
  <div ref="root"><!-- content --></div>
</template>

Svelte

<script lang="ts">
  import { onMount } from "svelte";
  import { highlightAll } from "@highlighters/core";

  onMount(() => {
    const handle = highlightAll({ color: { palette: "fluorescent", swatch: "green" } });
    return () => handle.remove();
  });
</script>

The highlight Svelte action (@highlighters/svelte) is for marking a single element's text in place via use:highlight. For page-wide or text-query marking, call core highlightAll / highlight as above.


Declarative scanning, summarised

Marker Effect Matched as
data-highlight Force this element to be marked by highlightAll [data-highlight]
data-highlight-exclude Skip this element's whole subtree in any page scan or the live selection [data-highlight-exclude]
<main>
  <h1 data-highlight>Marked because of the attribute</h1>
  <p>Marked by the page scan.</p>
  <pre data-highlight-exclude><code>not.marked();</code></pre>
</main>
highlightAll(); // picks up the page scan, the data-highlight, and skips the excluded <pre>

Related pages

Clone this wiki locally