Docs
overlapmark renders a text once, cut into segments, with one
nested <mark> per highlight. The more
highlights cover a spot, the darker it gets, and a click
reports every highlight under it. The same options and
events work in vanilla JS, Vue, React and Svelte.
Usage
Install the adapter for your framework, plus core for the stylesheet and types:
npm install @overlapmark/dom @overlapmark/core # vanilla JS
npm install @overlapmark/vue @overlapmark/core # Vue 3.3+
npm install @overlapmark/react @overlapmark/core # React 18+
npm install @overlapmark/svelte @overlapmark/core # Svelte 5+
Then render the text with its highlights. A selection adds a highlight; a click lists the highlights under it.
The quick starts use crypto.randomUUID()
for ids, which browsers only offer on HTTPS or
localhost. To highlight search results
instead of selections,
findMatches(text, query) from
@overlapmark/core returns
{ start, end }[].
In Vue the events are @select,
@click, @dblclick and
@hover; in React onSelect,
onClick, onDblClick and
onHover; in Svelte onselect,
onclick, ondblclick and
onhover. Each receives the same detail
object as in vanilla JS, not a framework or DOM event.
Highlight
A highlight is a plain object,
Highlight<T> from
@overlapmark/core:
Ranges are normalised before rendering: a non-integer, empty or reversed range is dropped, and a range outside the text is clamped to it, each with a dev warning. A range that splits a surrogate pair is widened to the whole character. Duplicate ids and ids with whitespace also get a warning.
findMatches(text, query, options?) finds
literal occurrences of a string or an array of strings.
Options: caseSensitive (default
false), overlapping (default
true) and wholeWord (default
false).
createHighlighter
createHighlighter<T>(el: HTMLElement, options: HighlighterOptions<T>): Highlighter<T>
From @overlapmark/dom. It replaces the
content of el with the rendered text, adds
the om-root class and sets
data-om-strategy and
data-om-overlap. The Vue, React and Svelte
Highlighter components take the same
options as props.
update() restores a selection at the same
text offsets, unless the text changed, and gives a
focused highlight its focus back on the re-rendered
element.
Options
Options of createHighlighter, or props of
the components:
Events
The detail types are SelectDetail,
PointDetail and
HoverDetail from
@overlapmark/core.
highlights in every event follow the
library order: start ascending, then longer
first, then input order. Try it: hover, click,
double-click or select below.
Overlap modes
With the default layers strategy, each
highlight covering a segment is its own nested
<mark>. The overlap
option decides how those layers combine. It only sets
data-om-overlap on the root; the stylesheet
does the rest. The depth strategy paints
one color per segment, darkened by its overlap count,
and ignores overlap.
multiply(default)-
Each layer multiplies with the layers under it
(
mix-blend-mode: multiply), like a highlighter pen drawn over itself, so every highlight darkens the spot it covers, for any color except pure white. The root is isolated, so layers never blend with the page behind it. Use it on a light background. A nested layer also tints its own text and decorations, and on a dark background pastels fade and light text loses contrast. Mixing hues from opposite sides of the color wheel turns stacks gray-brown, so prefer neighbouring hues for highlights that overlap. darken-
Each layer takes its own color mixed toward
--om-depth-inkby--om-depth-stepper nesting level, up to--om-max-depth, painted at--om-alpha. No blend mode, so text is never tinted. It darkens reliably only when the colors have similar lightness: a pale color nested in a saturated one still comes out lighter. Use it for dark themes or wherever blending is unwanted. none-
Plain translucent layers with no depth effect. Use
it when the overlap should not stand out, or when
you draw depth yourself from
[data-om-depth]or--om-level.
For dark themes, keep a light reading surface, or switch
to darken with deeper colors:
/* Option 1: the default multiply mode on a light reading surface. */
.article {
background: #fbf8f1;
color: #1d1d1f;
}
/* Option 2: overlap: 'darken' with deeper colors on the dark page. */
@media (prefers-color-scheme: dark) {
.article {
--om-default-color: #6d5bd0;
--om-alpha: 70%;
--om-depth-ink: black;
}
}
Styling
Import @overlapmark/core/styles.css, then
style at three levels. Every library selector is wrapped
in :where(), so your own class rules always
win. You can also skip the stylesheet and style the DOM
yourself; keep a visible focus style if you do.
1. Theme variables
Set them on .om-root or any ancestor:
2. Per highlight
Give a highlight a color and a
className, such as a preset:
The presets are made for the layers
strategy, where nesting shows. Write your own CSS
against these classes and attributes:
3. Decorations
Add presentational markup after the text of each
highlighted segment, such as an overlap count. It goes
in
<span data-om-skip aria-hidden="true">, stays out of offsets, selections and copies, and must
not contain anything focusable.
createHighlighter(el, {
text,
highlights,
decorate: (segment) => (segment.depth > 1 ? `×${segment.depth}` : null),
});
Accessibility
-
Keyboard. With
focusable: true, the default, every highlighted segment is a Tab stop, and Enter or Space reportsclick. Tab stops are per segment, not per highlight, so for densely annotated text setfocusable: falseand offer another way in, such as a list. -
Screen readers. A focusable segment
is
role="button"with anaria-descriptionfromdescribe, "2 highlights" by default. It is an attribute, so it never enters the text or the offsets.aria-descriptionis from the ARIA 1.3 draft; older Safari with VoiceOver may not announce it. -
Decorations are
aria-hidden: put what matters indescribe. -
Forced colors. Highlights are
painted in the system
Markcolors with an outline, and depth shows through the outlines of nested highlights.
Server rendering
The main entry of @overlapmark/core reads
no window or document, so it
runs on the server. The Vue, React and Svelte components
render the same markup on the server as on the client
and hydrate on it. Import
@overlapmark/core/styles.css on both sides.
@overlapmark/react starts with
'use client'. In React Server Components, a
Server Component can render
Highlighter with plain props, but when you
pass functions (handlers, describe,
brush, renderDecoration),
render it from a Client Component of your own.
Shadow DOM and iframes
The root may sit inside an open shadow root or in an iframe's document: selection, focus and offsets are read from the root's own tree and window.
- In a closed shadow root in Chromium, a drag that starts on a focusable highlight's first character selects nothing. Closed shadow roots are not tested.
- Slotted content in the root is not supported.
Limitations
-
Browsers. The stylesheet needs
color-mix(): Chrome and Edge 111, Firefox 113, Safari 16.2. Without it highlights have no fill. The acceptance tests run in Playwright's Chromium, Firefox and WebKit, desktop and emulated Pixel 7 and iPhone 15; no real device is tested. -
Framework versions. React 18+, Vue
3.3+ and Svelte 5+. The lowest versions run a
reduced suite. On Svelte 5.0, esrap 1.4 or later
fails to compile TypeScript components (
Not implemented type annotation); the tests pin esrap to 1.2.3 for Svelte 5.0.5, so pin it if you see that error. -
Ctrl+drag that starts on a
focusable highlight's first character selects
nothing in Chromium. Start the drag elsewhere, or
set
focusable: false. - Modules. The Vue, React and Svelte adapters ship ES modules only; core and dom ship ES modules and CommonJS.
-
Svelte. Every highlighted segment
gets a decoration container when a
decorationsnippet is given, and highlights held in deep$statearrive in events as state proxies; use$state.rawto compare with===.
The full list is in the README.
Migrating from vue-easy-highlight
overlapmark replaces vue-easy-highlight. In
Vue, render Highlighter from
@overlapmark/vue, compute the ranges
yourself and pass them as highlights with
ids.
| vue-easy-highlight 0.1.14 | overlapmark |
|---|---|
text |
text |
textsToHighlight (a Set)
|
highlights from
findMatches(text, [...set], {
overlapping: false }), each with an id
|
baseColor |
defaultColor, or
--om-default-color
|
textsBaseColorMap |
color on each highlight
|
selectedTextColor,
selectedTextBackgroundColor
|
Your CSS:
.om-root ::selection { … }
|
@text:selected |
@select="({ text, rect, start, end
}) => …"
|
@text:clicked |
@click="({ event, offset, segment,
highlights }) => …"
|
The old one-color-per-character darkening is closest to
strategy="depth". The old
text:selected skipped selections shorter
than 2 characters: use
:min-selection-length="2".