Library / SDK
alienzhou/web-highlighter avatar
alienzhou/web-highlighter

web-highlighter: save highlights on any web page, then restore them

A dependency-free library for highlighting and persisting text on any website ✨🖍️

988 stars159 forksTypeScriptMIT

At a glance

What is it?
web-highlighter is a dependency-free TypeScript library that lets a reader select text on a page, highlight it, and have those highlights persisted and restored later, the way a PDF reader does. It suits a blog, document viewer or online book that wants the feature, and it depends on the document structure staying the same between visits.
Who is it for?
Adopt web-highlighter if you are building a document viewer, blog or online book and want readers to keep their highlights across visits, because the library handles the selection, the wrapping and the restore cycle and leaves you the storage. Do not adopt it for a page whose content changes between visits, since persisted highlights record DOM paths and text offsets rather than coordinates, and the README is explicit that they may no longer be valid after a re-render.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The feature it takes from a PDF reader

web-highlighter is a dependency-free library for highlighting and persisting text on any website, and the Background section explains where the idea came from.

The project began with a simple idea: highlight text on a website and save those highlights, much like you would in a PDF. The reference point given is Medium, where a reader selects a text segment and clicks Highlight, the selected text receives a highlighted background, and the highlight is saved and restored on later visits.

That reference is doing more work than it appears to. web-highlighter turns it into a library you can drop into a page you do not control, and the README says it helps you implement text highlighting and persistence on websites such as blogs, document viewers and online books, providing the core functionality and easy-to-use APIs for building your own product, and that it is used in production.

The library reads the current selection through the Selection API and converts it into a serializable HighlightSource. You can persist that data in a backend and restore the highlight when the user returns to the page. And the data format is independent of the technology stack, so it works with pages built using React, Vue, Angular, jQuery and other libraries.

That last point is the design decision that makes it reusable. The persisted record describes text positions, not framework state, so the same stored highlights survive a migration from one rendering library to another.

Two lines for highlighting, four for persistence

The usage section shows the smallest possible integration, and it is genuinely two lines.

javascript
import Highlighter from 'web-highlighter';
new Highlighter().run();

Adding these two lines automatically highlights selected text. The run call is what attaches the behaviour, and the constructor is where you would pass options.

Persistence is where the API gets interesting, and the README lays it out as four numbered steps. Initialize the highlighter. Retrieve persisted data from the backend and restore the highlights with fromStore, passing the start meta, the end meta, the text and the id. Save newly created highlights to the backend by listening for the CREATE event. Then enable automatic highlighting with run.

javascript
import Highlighter from 'web-highlighter';

// 1. Initialize the highlighter.
const highlighter = new Highlighter();

// 2. Retrieve persisted data from the backend, then restore highlights.
getRemoteData().then(source => highlighter.fromStore(
    source.startMeta,
    source.endMeta,
    source.text,
    source.id,
));

// 3. Save newly created highlights to the backend.
highlighter.on(Highlighter.event.CREATE, ({ sources }) => save(sources));

// 4. Enable automatic highlighting.
highlighter.run();

The separation between step two and step four is the important part. Restoring from a store is something you do explicitly, at a moment you choose, and it is a separate call from run. That is not an accident of the API design, it is the answer to the constraint described later, where a restore performed before the content has rendered would attach highlights to the wrong elements.

The options and events, and what they let you style

The longer example shows the configuration surface, and it is where the interaction design becomes visible.

javascript
import Highlighter from 'web-highlighter';

// Do not highlight text within <pre> or <code> elements.
const highlighter = new Highlighter({
    exceptSelectors: ['pre', 'code'],
});

// Handle highlight interactions.
highlighter
    .on(Highlighter.event.HOVER, ({ id }) => {
        highlighter.addClass('highlight-wrap-hover', id);
    })
    .on(Highlighter.event.HOVER_OUT, ({ id }) => {
        highlighter.removeClass('highlight-wrap-hover', id);
    })
    .on(Highlighter.event.CREATE, ({ sources }) => {
        store.save(sources.map(hs => ({ hs })));
    });

// Restore persisted highlights.
store.getAll().forEach(({ hs }) => {
    highlighter.fromStore(hs.startMeta, hs.endMeta, hs.text, hs.id);
});

highlighter.run();

The exceptSelectors option takes a list of selectors to skip, and the example skips pre and code, which is the case where a code block is the content rather than the subject of the text.

The events are HOVER, HOVER_OUT and CREATE, and the way the hover events are used is the interesting part. Rather than emitting a hover and leaving the styling to you, the library exposes addClass and removeClass keyed by the highlight id, so the hover state is a class on the highlight wrapper and you decide what that class looks like. The example names the class highlight-wrap-hover, which is a naming convention you can match in your stylesheet.

The chaining in the example is also the intended style, since on returns the highlighter, so the event handlers can be registered as a chain and the create handler is where the persistence write happens.

Persisted positions are DOM paths and text offsets, not coordinates

The persistence constraints section is the part of the README that decides whether this library is usable for your project, and it is unusually blunt.

Persisted highlights record DOM paths and text offsets relative to $root, and they are not screen coordinates. You should restore highlights only after content is fully rendered, using a compatible $root, document structure, text segmentation and wrapTag configuration. If a framework re-renders the content or the document changes, persisted positions may no longer be valid. The README points at the Serialize.Restore hook for implementing a restoration strategy for changing content.

So the contract is: the same document, the same segmentation, the same wrapper configuration, and the restore has to happen after render. That is a real constraint, and it rules out the case where your page is generated fresh on every request with different content lengths.

The reason for the design is that coordinates would be worse. A screen position cannot be restored at all, and a text offset is at least a stable identifier that survives a re-render as long as the text before the highlight has not changed length. The DOM path adds the structural part, so a highlight in the third paragraph of the third section is still findable after a style change.

The escape hatch is the hook. The Serialize.Restore hook is where you write the strategy for a page that does change, and the natural implementation is to match on content rather than position when the position no longer resolves.

The cross-paragraph case gets its own section, and it is a consequence of the same design. A cross-paragraph selection creates a single continuous HighlightSource, so if your application requires one persisted record per paragraph, you split the selection into a separate Range for each paragraph before creating highlights, call fromRange for each range and persist each returned HighlightSource separately. The Serialize.RecordInfo hook is where you store paragraph-specific identifiers in source.extra, and the example does exactly that, taking the start and end paragraph ids from a data attribute on the closest p element.

Removal is destructive to the text nodes, and the README says so

The last piece of the contract is about removal, and it is the kind of detail that gets discovered the hard way.

remove(id) and removeAll() remove the highlight wrappers and restore the original element hierarchy and attributes, including for cross-paragraph highlights. But browser text splitting and merging means the original TextNode object identities and event listeners attached directly to TextNodes are not preserved, and the advice is to delegate events from a stable parent element instead.

That is a genuine limitation and a common one in any library that wraps text. Wrapping a text range means splitting the text node it came from, and unwrapping means merging the pieces back, and the browser does not guarantee you get the same object back. So a listener you attached directly to a text node before highlighting will be gone afterwards, and a listener attached to the parent survives.

The practical consequence is a rule for your own code: never attach an event listener directly to a text node inside a highlightable region, and delegate from a container instead. The README tells you this, but it is the kind of thing that is easy to miss and hard to debug.

On the packaging side, the project publishes ES5 output with IE11 support and uses Node.js 22.15.1 for development, with the browserslist covering IE 11, the last two versions of Chrome, Firefox and Safari. The main and browser fields both point at dist/web-highlighter.min.js, the types field at dist/index.d.ts, and the only published file is dist. The build runs webpack through script/build.js, the types are built with tsc and tsc-alias, and the test script chains a build, unit tests, a distribution test and a package test, with mocha, ts-node, nyc for coverage and husky for the commit hooks.

Editorial conclusion

Adopt web-highlighter if you are building a document viewer, blog or online book and want readers to keep their highlights across visits, because the library handles the selection, the wrapping and the restore cycle and leaves you the storage. Do not adopt it for a page whose content changes between visits, since persisted highlights record DOM paths and text offsets rather than coordinates, and the README is explicit that they may no longer be valid after a re-render. Before you ship it, decide when to restore, which the README says means after content is fully rendered, and read the restore hook documentation, because a single page re-render will otherwise leave stale highlights on the page.

Frequently asked questions

How do I add text highlighting to a page with web-highlighter?

Import the library and run it. `import Highlighter from 'web-highlighter'; new Highlighter().run();` is the whole integration, and the package is installed with `npm i web-highlighter`. The library is dependency-free and publishes ES5 output with IE11 support.

How do I persist and restore highlights in web-highlighter?

Create the highlighter, call fromStore with the start meta, end meta, text and id to restore, listen for Highlighter.event.CREATE to save new highlights to your backend, then call run to enable automatic highlighting. The README is explicit that you should restore only after content is fully rendered.

What are the limits of persisted highlights in web-highlighter?

They record DOM paths and text offsets relative to $root rather than screen coordinates, so restoring requires a compatible $root, document structure, text segmentation and wrapTag configuration, and positions may be invalid after a framework re-render or a document change. The Serialize.Restore hook is the documented way to implement a restoration strategy for changing content.

What happens to my text nodes when a highlight is removed?

remove(id) and removeAll() remove the wrappers and restore the original element hierarchy and attributes, but because browser text splitting and merging do not preserve the original TextNode object identities, event listeners attached directly to TextNodes are lost. The README advises delegating events from a stable parent element instead.

Official sources

  1. alienzhou/web-highlighter on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/alienzhou-web-highlighter.svg)](https://hysenlabs.com/projects/alienzhou-web-highlighter)

Was this useful?