Skip to main content

Vanilla engine (no framework)

one-more-highlight/vanilla highlights text in any DOM subtree with no framework at all. It reads the subtree, matches across element boundaries, and paints with the CSS Custom Highlight API — falling back to <mark> injection where that API is missing.

When to use it​

  • You are not using React: plain JS, Vue, Svelte, Angular, Astro, a CMS theme, a browser extension.
  • You need to highlight markup you do not own — a rendered article, search results, a third-party widget — rather than text you pass in as a string.
  • You want the multi-state model over content that already exists in the page.

If you are rendering the text yourself in React, use <Highlight> or one-more-highlight/css instead. They own their text node, so they skip the walk entirely.

Opt in​

npm i one-more-highlight
import { Highlighter } from 'one-more-highlight/vanilla';

const h = new Highlighter(document.querySelector('#article'));
const count = h.mark('time');
::highlight(match) { background: #FFF166; color: #1b1b1d; }

react is an optional peer dependency — a non-React project installs nothing extra.

From a CDN, with no build step​

The package ships an IIFE bundle (dist/omh.global.js) that defines a single global, OMH. There is nothing to deploy — jsDelivr and unpkg mirror npm automatically, so every published version is already there.

<script src="https://cdn.jsdelivr.net/npm/one-more-highlight/dist/omh.global.js"></script>
<script>
const h = new OMH.Highlighter(document.body);
h.mark('time');
</script>

The package declares unpkg and jsdelivr fields pointing at that file, so the bare URL resolves to it too:

<script src="https://cdn.jsdelivr.net/npm/one-more-highlight"></script>
<script src="https://unpkg.com/one-more-highlight"></script>

The bundle is ~9 kB raw, ~3.2 kB brotlied over the wire, with escape-string-regexp inlined and process.env compiled out. Everything the Highlighter API offers is on OMH — Highlighter, highlight, TextIndex, supported, defaultFindChunks.

Pin the version, and add an integrity hash​

The examples above are unversioned, which is fine while you are trying it out and wrong in production — the file changes under you on every release. Pin it:

<script src="https://cdn.jsdelivr.net/npm/one-more-highlight@1.7.0/dist/omh.global.js"></script>

A pinned URL can also carry Subresource Integrity, so a compromised or mistaken CDN response is rejected by the browser instead of executed. The hash below is the real one for 1.7.0; regenerate it whenever you move to a new version:

curl -s https://cdn.jsdelivr.net/npm/one-more-highlight@1.7.0/dist/omh.global.js \
| openssl dgst -sha384 -binary | openssl base64 -A
<script
src="https://cdn.jsdelivr.net/npm/one-more-highlight@1.7.0/dist/omh.global.js"
integrity="sha384-JGLvWQ+yeOWtQ9yyfV7aZJO2/hJnSGGHdXZZexQt8sXMz9dlqsm2KXW6xT0HTEa2"
crossorigin="anonymous"></script>

crossorigin="anonymous" is required — without it the browser cannot read the response to verify it, and the script is blocked. Note that SRI only works against a pinned version: a range URL like @1 serves different bytes over time, so any hash you compute for it will start failing at the next release.

If you would rather have ESM than a global, esm.sh serves the real subpath:

<script type="module">
import { Highlighter } from 'https://esm.sh/one-more-highlight/vanilla';
new Highlighter(document.body).mark('time');
</script>

Multi-state styling​

Every state becomes a separately addressable CSS highlight. This is the same states union the React entry takes, minus the DOM style field — under the css renderer you style by name, so an inline style object has nowhere to go.

h.mark('time', {
states: [
{ name: 'active', index: 2, className: 'is-active' },
{ name: 'preview', range: [0, 1], className: 'is-preview' },
{ name: 'flagged', indices: [0, 4], className: 'is-flagged' },
],
});
::highlight(match) { background: #FFF166; }
::highlight(active) { background: #A8FF80; }
::highlight(preview) { background: #FFADD6; }

Under the mark renderer the same states contribute their className to the injected element instead, and the element carries data-omh-states="active".

Highlight names are global to the document​

This is the one thing about the css renderer that surprises people, and it is not a quirk of this library — it is how the platform API works. CSS.highlights is a document-wide registry keyed by name. Two Highlighter instances that use the same state name write into the same bucket:

// Both instances register under the name 'match'.
const a = new Highlighter(document.querySelector('#sidebar'));
const b = new Highlighter(document.querySelector('#article'));
a.mark('term');
b.mark('term');

a.unmark(); // b's highlights survive — each instance deletes only its own
// ranges, and drops the name only once the bucket is empty.

Sharing a name is often exactly what you want: one ::highlight(match) rule styles every highlighter on the page. It becomes a problem when two instances mean different things by the same name — a search highlighter and a spell-checker both calling their state 'active' will paint each other's matches.

So when a page runs more than one highlighter for different purposes, namespace the names:

const search = new Highlighter(el, { name: 'search-match' });
search.mark(query, {
states: [{ name: 'search-active', index: cursor }],
});

Teardown is reference-counted and per-instance, so instances that do share a name coexist safely — unmark() removes only the ranges that instance added, and deletes the registry entry only when nothing is left in it. The mark renderer has no such consideration: it writes elements into the DOM it was given and touches nothing else.

Matching across element boundaries​

The walker flattens every text node under the root into one string before matching, so a phrase split by inline markup is a single match:

<b>an eleme<em>nt bound</em>ary</b>

h.mark('an element boundary') returns 1. Under the css renderer that is one Range spanning three text nodes; under mark, three wrapper elements, because one element cannot straddle the boundary.

<script>, <style> and <noscript> content is always excluded.

Controlled ranges​

The parity for the core's ranges prop. Offsets are into the flattened subtree text, which .text exposes so you can compute against exactly what the matcher saw:

const h = new Highlighter(el);
h.text; // "hello world" — for <p>hello <b>world</b></p>
h.markRanges([{ start: 6, end: 11 }], [{ name: 'cited', index: 0 }]);

Renderers​

cssmark
DOM mutationnonewraps matches in <mark>
Cross-element matchone Rangeone wrapper per text node
Styling::highlight(name)className + data-omh-states
Survives a framework re-renderyesno — re-mark() after

strategy: 'auto' (the default) picks css when the browser supports it and mark otherwise. Force one with { strategy: 'css' } or { strategy: 'mark' }, and read back which one an instance resolved to via h.renderer.

API​

new Highlighter(root: Element | Document | DocumentFragment, opts?: {
strategy?: 'auto' | 'css' | 'mark'; // default 'auto'
name?: string; // implicit highlight name, default 'match'
overlapStrategy?: 'merge' | 'nest' | 'first-wins';
collapseWhitespace?: boolean; // default true
filter?: (node: Text) => boolean;
shadow?: boolean; // walk open shadow roots, default false
markTag?: string; // default 'mark'
markClassName?: string; // default 'omh-match'
})

h.mark(query, { states?, caseSensitive?, autoEscape?, sanitize? }): number
h.markRanges(ranges, states?): number
h.text: string // the flattened subtree text
h.renderer: 'css' | 'mark'
h.unmark(): void // restores the DOM under the `mark` renderer
h.destroy(): void // alias for unmark

Each call to mark() replaces that instance's previous marking. Separate instances are independent — tearing one down leaves the other's highlights registered.

Headless​

If you only want the segments and will render them yourself:

import { highlight } from 'one-more-highlight/vanilla';

const segments = highlight({
text: 'time time time',
searchWords: ['time'],
states: [{ name: 'active', index: 1 }],
});

This is the same pipeline useHighlight runs, without React and without memoization — outside a component there is no render to key a cache against, so caching is the caller's job.

Limitation: TypeScript with skipLibCheck: false​

The emitted declarations carry a bare import 'react' inherited from the shared type chunk. It is inert under skipLibCheck: true — TypeScript's own default, and verified against a project with no React types installed. If you turn skipLibCheck off, add @types/react as a dev dependency.