-
-
Notifications
You must be signed in to change notification settings - Fork 3
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.
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: noneanduser-select: none, so it never intercepts clicks or selection. -
isolation: isolatewithmix-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-apitier (Tier C) is the exception to "overlay": it paints via the native CSS Custom Highlight API (::highlight()) over registeredRanges, 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.
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.)
@highlighters reads prefers-reduced-motion: reduce and adapts automatically. There is nothing to wire up.
When the user prefers reduced motion:
-
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. -
The renderer degrades a tier. Under
renderer: "auto", aprefers-reduced-motion: reduceenvironment 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. -
Speed-aware ink is disabled. The live-selection
speeddynamics never engage; the velocity tracker is not even constructed. - 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.
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.
-
highlightAll()additionally attaches a debouncedMutationObserverso nodes added to the page get marked and removed nodes drop their marks, without a full rescan. See Page-Highlighting. -
highlightSelection()is driven byselectionchangeand rebuilds as the user drags. See Selection-Highlighting.
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.
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.
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, default0.55) - lower it if a saturated swatch is too heavy over body text. -
color/palette- the built-inmildfamily (the library default) andneutralfamily are deliberately desaturated for legibility over running text;fluorescentis 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
contrastBackgroundfield 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-apitier,::highlight()exposes no blend mode, so multiply is dropped and the chosenopacityis folded into the fill colour viacolor-mixinstead. The colour and coverage still match the other tiers. See Performance.
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.
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 });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>;
}<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><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>-
The text is the source of truth. Selection, find-in-page, copy, and screen readers all read the unmodified text. The overlay is
aria-hiddenand 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: relativeto 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
opacityor pick a desaturated palette for dense copy. -
semanticandcontrastBackgroundare 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.
- 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-apitier. -
Animation - the draw-on entrance and how
prefers-reduced-motionsuppresses it. - Selection-Highlighting - live selection, the touch fallback, fade-on-clear, and speed-aware ink.
-
Page-Highlighting - the
MutationObserverthat keeps declarative marks in sync. -
Color-and-Palettes - the multiply optic, opacity, and the desaturated
mild/neutralfamilies. - Ink-and-Optics - blend modes and how the ink deposits.
-
SSR-Support - the DOM-free
/pathentry and which exports are SSR-safe. - Options-Reference - every option, default, and the field-wise merge rules.
Getting Started
The Mark
- Mark-Types-and-Shapes
- Tips-and-Edges
- Ink-and-Optics
- Color-and-Palettes
- Snapping-and-Overshoot
- Animation
Targeting
Reference
Production