There are now multiple ways of achieving this effect without modifying the element structure of the DOM.
They all, however, come with their own caveats.
This answer is derived from research I performed for my open source browser extension Mark My Search (repository), which highlights text you search for on webpages. The natural destructiveness of the accepted approach led to my consideration of many alternatives. The extension will only use one the below approaches if the advanced option "Use CLASSIC highlighting" is unchecked, which is not default due to the disadvantages I am about to describe.
All of my approaches below only involve adding additional painting to the DOM, so there is no option to style text or otherwise change existing content. However, they are entirely non-destructive operations which can result in efficiency gains, making them a competitive alternative.
Important: The assumption through this answer is that you either apply styling directly to elements, or use a stylesheet and generated attributes to apply it remotely. This type of DOM manipulation is still required.
Approaches
Monolithic
Pros:
- Simple, obvious.
- All highlighting managed in one place.
- Efficient for mutations of many elements at once.
Cons:
- Many compromises. If you choose to overlay the highlights, opacity of highlighting is necessarily a compromise between visibility and obscuring text. If you underlay, you must remove all backgrounds to avoid highlights being hidden.
- Inefficient for mutations of few elements, as everything must be recalculated at once (partially mitigated by caching).
- Inefficient for implementations that perform best in small, disparate frames.
Composite
For all text to be highlighted, look at the lowest-level distinct elements which contain parts of the text ranges.
Draw highlighting onto each of these elements.
- Get client rects of the required ranges of text.
- Compare with the client rects of the specific containing element, obtaining the relative drawn text positions.*
- Apply one of the implementations below to the containing element.
Pros:
- Removes problem of highlights being hidden by element backgrounds.
- Layout shifts will to an extent carry highlighting with them, reducing the need for vigilant updates/recalculations.
- Much more pertinent to underlay instead of overlay, so less compromise between obscuring text and emphasising it.
- Efficient for updates of few elements, as highlighting is modular and decomposed into a range of distinct areas.
Cons:
- Complex, nuanced.
- Under some implementations, one background (normally
background-image
) is still potentially lost per highlighted element.
- Inefficient for mutations of many elements at once
- Inefficient for implementations that perform best in large, few frames.
Hybrid
- Combine highlighting responsibility into a few elements (for example, ones with
display: block
to handle highlights for its display: inline
children).
Implementations
Elements
- Create highlighting elements of appropriate sizes.
- Absolutely position them such that they cover the intended areas.
Pros:
- Faultless browser support.
- Infinite effects are available.
Cons:
- Heavyweight, slow, DOM-reliant.
- Adds many superfluous elements to the main structure of the page, disruptive.
SVG
- Percent-encode an SVG drawing the laid out highlighting elements.
- Use
url()
on the background-image
of the ancestor element.
Pros:
- Pretty fast.
- Compatible with modern browsers.
Cons:
- Page CSPs will (rarely) block the use of
url()
, expecting it to extract remote content.
Canvas
- Percent-encode a canvas drawing the laid out highlighting elements, or use a
<canvas>
element.
- Use
url()
on the background-image
of the ancestor element.
Pros:
- Compatible with modern browsers.
Cons:
- Extremely slow after only a few canvases (especially large ones), presumably due to each pixel being explicitly painted. Seems to be poorly optimised, particularly for transparent pixels.
- If
url()
is used: Page CSPs will (rarely) block the use of url()
, expecting it to extract remote content.
Houdini
- Register a Paint worklet that draws highlights based on sizing and positioning CSS variables, with whatever additional info you deem necessary (see details of Houdini Paint API).
- Use
background-image: paint({worklet name})
as appropriate on elements to display highlighting.
- Define CSS variables (the ones consumed by the worklet) on the same element - consider using a stringified JSON object for this which will be unpacked within the worklet, it s surprisingly efficient.
element()
- Create highlighting elements of appropriate sizes.
- Absolutely position them such that they cover the intended areas, in a dedicated portion of the DOM (offscreen).
- Use
background: element({ID of highlighting element})
as appropriate on elements to display highlighting.
Pros:
- Infinite effects are possible.
- Much more viable than the direct DOM alternative.
Cons:
- Specific to Firefox, on which it is experimental but has been supported for years.
- For some reason requires application to
background
instead of just background-image
, resulting in more overriding of element backgrounds. This seems to be an oversight.
- Heavyweight, a little slow, DOM-reliant.
Dynamically Updating
I won t go into much detail about this, as it is very specific to your needs. However, to obtain fully up-to-date highlighting when applied to an entire page, I needed:
A MutationObserver
observing subtree
, childList
, and characterData
for the document body, in order to detect all changes that may affect matching. My case is very extreme, which is why I must observe the entire document; I optimised using various methods, including filtering out events that did not require attention.
A ResizeObserver
observing highlighted elements currently in view, in order to recalculate highlighting when layout shifts occur.
An IntersectionObserver
observing all highlighted elements for when they appear in the viewport, in order to draw highlighting and the ResizeObserver
only when needed. This is an optimisation measure only.
Information
Here is the PR in which I achieved this, using multiple of the above methods which could be swapped out. I used a highly structured approach - involving caching across the DOM and other optimisations, such as decomposing it into multiple stages - to make it efficient even for cases where all of an average page has been highlighted in various colours. An overview is presented in the PR.
This being one of the most extreme possible cases, it should be entirely possible for others to achieve good results without significant performance loss.
I am happy to answer any questions regarding my implementation, or getting started with these technologies.
Articles
Notes
*Due to convoluted browser behaviour when it comes to the association of client rects with actual layout+position, it is very difficult to make sure an overlay-based highlighting algorithm will position highlight-boxes correctly in all cases. I have still not managed to correctly account for the effect of border (which disrupts calculations) or strange cases of flow content, but it is technically possible using only the results of Range.getClientRects()
and Element.getClientRects()
.