overlapmark

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.

    overlap: 'multiply'
    overlap: 'darken'
    overlap: 'none'
    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-ink by --om-depth-step per 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 reports click. Tab stops are per segment, not per highlight, so for densely annotated text set focusable: false and offer another way in, such as a list.
    • Screen readers. A focusable segment is role="button" with an aria-description from describe, "2 highlights" by default. It is an attribute, so it never enters the text or the offsets. aria-description is from the ARIA 1.3 draft; older Safari with VoiceOver may not announce it.
    • Decorations are aria-hidden: put what matters in describe.
    • Forced colors. Highlights are painted in the system Mark colors 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 decoration snippet is given, and highlights held in deep $state arrive in events as state proxies; use $state.raw to 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".