Skip to content

Accessibility and Reflow

Jace edited this page Jun 6, 2026 · 1 revision

Accessibility and Reflow

@highlighters never touches your text. A mark is an overlay painted in a separate, out-of-flow layer that sits over the words; the text nodes underneath are left exactly as they were. That single decision is what keeps selection, find-in-page, and screen readers working, what keeps the mark from ever shifting layout, and what lets the library reposition cleanly when the page reflows or a web font loads late. This page covers the accessibility guarantees, the reduced-motion behaviour, the reflow/reposition machinery, and the multiply optic that keeps text legible.

The short version is in the README: text is never altered; overlays are aria-hidden and non-interactive; selection and find-in-page keep working. This page is the detail behind that line.


Overlay-only: the text is never modified

For the two organic tiers (svg and css), a mark is rendered into a single overlay container appended to the host element. The container is, by construction:

  • Absolutely positioned and out of layout flow, so a mark can never cause layout shift (no CLS).
  • aria-hidden="true", so assistive technology never announces it.
  • pointer-events: none and user-select: none, so it never intercepts clicks or selection.
  • isolation: isolate with mix-blend-mode: multiply, so the ink composites against the intended backdrop (see legibility below).

Because the text DOM is untouched, every browser and AT feature that reads the text keeps working unchanged:

Feature Why it keeps working
Text selection The words are still real, selectable text nodes. The overlay is user-select: none and pointer-events: none, so it never gets in the way of a drag.
Find-in-page (Ctrl/Cmd-F) The browser searches the live text content, which the library never rewrites.
Screen readers The overlay is aria-hidden; the reading order and accessible name of the underlying content are unchanged.
Copy / paste Copied text contains no marker artefacts. There is nothing to strip.
Reading mode / translation / user stylesheets They operate on the unmodified document.

remove() deletes the overlay and, when it was the last mark on a host, the container too, leaving the DOM exactly as it was before the mark was applied.

The highlight-api tier (Tier C) is the exception to "overlay": it paints via the native CSS Custom Highlight API (::highlight()) over registered Ranges, with no overlay DOM at all. It is the maximally-safe tier - text nodes untouched, native multiline, find-in-page and selection unaffected. See Performance for tier details.

Want a real <mark> element?

If you need the mark to be semantic in the accessibility tree (announced as highlighted, exposed as a <mark> to AT), wrap your own <mark> in the markup and target it:

// Author the <mark> yourself; highlight its text.
highlight("mark.key-term");
<p>The <mark class="key-term">anchored grid</mark> keeps the wobble stable.</p>

The overlay paints over your <mark> and the element carries the semantics. (HighlightOptions has a semantic field in the type, but treat it as schema-only: the current renderer does not generate a <mark> for you. Author it in your own markup when you need it.)


Reduced motion

@highlighters reads prefers-reduced-motion: reduce and adapts automatically. There is nothing to wire up.

When the user prefers reduced motion:

  1. The entrance animation is suppressed. The draw-on swipe is skipped entirely; the mark appears in its final state. This is gated inside the animation runner, so it applies no matter how you set animation.draw / duration / stagger. See Animation.
  2. The renderer degrades a tier. Under renderer: "auto", a prefers-reduced-motion: reduce environment drops Tier A (svg) to Tier B (css): a flat CSS band with no SVG filters or texture. This is a fidelity-only change; the mark's position and colour are identical. See Performance and How-It-Works.
  3. Speed-aware ink is disabled. The live-selection speed dynamics never engage; the velocity tracker is not even constructed.
  4. Fade-on-clear becomes instant. When a live selection empties, the overlay normally fades out over 200ms; under reduced motion it clears instantly instead.

If you want to opt out of motion regardless of OS setting, set animation: { draw: false }. If you want to force a tier rather than auto-degrade, pin renderer (see Performance).

// Respect the OS setting (default). Nothing to do.
highlight("#intro");

// Hard-disable the draw-on for everyone.
highlight("#intro", { animation: { draw: false } });

Reduced motion is read once per mount via matchMedia. It does not live-toggle an existing mark when the OS setting changes mid-session; the next mount picks up the new value.


Reflow and web-font reposition

A mark's geometry is computed in absolute pixels from the text's line rects, then reused until something moves the text. Every mark owns a reflow observer that funnels each event that can move it into one requestAnimationFrame-batched rebuild. The geometry is recomputed against the new line rects; nothing swims, because the wobble is anchored to a layout-stable grid (see How-It-Works and Tips-and-Edges).

Three sources feed the observer:

Source What it catches
ResizeObserver on the host and its nearest positioned container The element (or its container) changing size and rewrapping the text.
window resize Viewport changes that rewrap text without resizing the observed element.
document.fonts.ready A late-loading web font. When the font swaps in, glyph metrics change and text reflows; the mark repositions once fonts are ready.

The web-font case is the subtle one. If you highlight text that is still rendering in a fallback font, the mark is drawn against the fallback metrics. The moment the real font loads, the text shifts, and document.fonts.ready fires a single rebuild so the mark snaps back over the words. You do not need to delay highlighting until fonts load, and you do not need to re-call anything.

The observer is requestAnimationFrame-batched (a burst of resize events collapses to one rebuild), leak-free (its Disconnect is folded into the handle's remove()), and inert off-DOM (it is a no-op during SSR). You never construct it directly when using the entry points; it is wired automatically.

Page and live modes also watch the DOM

  • highlightAll() additionally attaches a debounced MutationObserver so nodes added to the page get marked and removed nodes drop their marks, without a full rescan. See Page-Highlighting.
  • highlightSelection() is driven by selectionchange and rebuilds as the user drags. See Selection-Highlighting.

Advanced: the observer is exported

If you are building your own pipeline on the geometry builders, createReflowObserver is exported from @highlighters/core (it touches the DOM, so it is not in the SSR-safe /path entry):

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

const disconnect = createReflowObserver([el], () => {
  // recompute + repaint your overlay here
});

// later
disconnect();

See API-Reference for the full list of advanced exports and SSR-Support for which entry point to import from.


Live selection and touch

highlightSelection() makes one extra accessibility decision: on coarse pointers (touch) it returns an inert no-op handle and paints nothing, deferring to the platform's native selection UI. A floating overlay over a touch selection fights the OS selection handles and the magnifier; native is the better, more accessible experience there. On fine pointers (mouse/pen) the overlay paints as normal.

The optional speed-aware ink (speed) is also live-selection-only, fine-pointer-only, and suppressed under reduced motion - so it never affects programmatic, keyboard, or touch selections. See Selection-Highlighting.


Legibility: the multiply optic

A flat opaque highlight can wash out the text under it. @highlighters defaults blendMode to multiply, which is how real highlighter ink behaves: it is subtractive, so the colour darkens whatever is beneath it rather than covering it. Dark text stays dark (multiplying by near-black stays near-black), the page colour shows through as the marker hue, and overlapping marks deepen instead of stacking into opacity.

The default opacity is 0.55, tuned so the ink reads as a marker without obscuring the text. The overlay container sets isolation: isolate so the multiply composites against the intended backdrop rather than leaking into an ancestor stacking context.

Two knobs help you keep contrast where you want it:

  • opacity (0-1, default 0.55) - lower it if a saturated swatch is too heavy over body text.
  • color / palette - the built-in mild family (the library default) and neutral family are deliberately desaturated for legibility over running text; fluorescent is punchier for headlines. See Color-and-Palettes.
// Default: multiply ink at 0.55, mild yellow. Legible over body copy.
highlight("p.lead");

// Lighter deposit for dense paragraphs.
highlight("article p", { opacity: 0.4 });

// Keep multiply but switch to a calmer palette.
highlight(".note", { palette: "neutral" });

A contrastBackground field exists on the type and is described as driving a dev-time WCAG-contrast check, but treat it as schema-only: the current build does not emit a contrast warning. Verify contrast with your usual tooling. Whatever you choose, the multiply blend keeps text legible by darkening rather than covering, which is the property that matters most.

On the highlight-api tier, ::highlight() exposes no blend mode, so multiply is dropped and the chosen opacity is folded into the fill colour via color-mix instead. The colour and coverage still match the other tiers. See Performance.


Examples by framework

Accessibility is automatic, so most of this is "do nothing." These examples show the few things you might set explicitly - reduced-motion opt-out, a lighter deposit, semantic markup.

Vanilla

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

// Overlay-only, multiply, reflow-aware, reduced-motion-aware: all by default.
highlight("#intro");

// Target your own <mark> for semantics; lighter ink for body text.
highlight("mark.term", { opacity: 0.45 });

React

import { Highlight } from "@highlighters/react";

function Lead() {
  return (
    <Highlight as="p" options={{ opacity: 0.45 }}>
      Selection and find-in-page keep working under this mark.
    </Highlight>
  );
}
import { useRef } from "react";
import { useHighlight } from "@highlighters/react";

function Term() {
  const ref = useRef<HTMLElement>(null);
  // Wrap your own <mark> so AT announces the highlight; the overlay paints over it.
  useHighlight(ref, { animation: { draw: false } });
  return <mark ref={ref}>idempotent</mark>;
}

Vue

<script setup lang="ts">
import { Highlight } from "@highlighters/vue";
</script>

<template>
  <Highlight as="p" :options="{ opacity: 0.45 }">
    The text underneath is never modified.
  </Highlight>
</template>

Svelte

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

<!-- Author the <mark> for semantics; the action paints the overlay over it. -->
<mark use:highlight={{ animation: { draw: false } }}>anchored grid</mark>

Notes and gotchas

  • The text is the source of truth. Selection, find-in-page, copy, and screen readers all read the unmodified text. The overlay is aria-hidden and non-interactive by construction. Nothing you pass in changes that.
  • No layout shift. The overlay is out of flow and absolutely positioned, so applying or removing a mark never reflows the page. A static host is promoted to position: relative to anchor the overlay, which does not move it.
  • Reduced motion is automatic. Draw-on suppressed, tier degraded, speed ink off, fade-on-clear instant. Set animation: { draw: false } to opt out of motion regardless of OS setting.
  • Web fonts just work. Highlight before fonts load; the mark repositions once on document.fonts.ready. No need to wait.
  • Touch defers to native. highlightSelection() is a no-op on coarse pointers, so the OS selection UI stays in charge.
  • Multiply, not cover. The default blend darkens the text's backdrop rather than painting over it, so dark text stays legible. Lower opacity or pick a desaturated palette for dense copy.
  • semantic and contrastBackground are schema-only. They exist on the type but the current renderer does not act on them. Author <mark> yourself for semantics and check contrast with your own tooling.

See also

  • How-It-Works - the overlay model, the anchored grid, and why marks never swim on reflow.
  • Performance - renderer tiers, the auto-degrade rules (reduced-motion, reduced-data, mark count), and the highlight-api tier.
  • Animation - the draw-on entrance and how prefers-reduced-motion suppresses it.
  • Selection-Highlighting - live selection, the touch fallback, fade-on-clear, and speed-aware ink.
  • Page-Highlighting - the MutationObserver that keeps declarative marks in sync.
  • Color-and-Palettes - the multiply optic, opacity, and the desaturated mild / neutral families.
  • Ink-and-Optics - blend modes and how the ink deposits.
  • SSR-Support - the DOM-free /path entry and which exports are SSR-safe.
  • Options-Reference - every option, default, and the field-wise merge rules.

Clone this wiki locally