penciled: Persistent Text Highlights and Notes in JavaScript

Category: Javascript , Text | October 22, 2026
Authormarcellosso
Last UpdateOctober 22, 2026
LicenseMIT
Views0 views
penciled: Persistent Text Highlights and Notes in JavaScript

penciled is a lightweight JavaScript text annotation library that lets your readers highlight passages and attach notes directly to web pages.

Highlights survive page reloads, and readers can edit or remove them through a floating toolbar.

It works with ordinary HTML content, supports custom storage for cross-device access, and includes a headless engine for sites that need their own annotation UI.

Features

  • 5 preset highlight colors and custom color palettes.
  • Optional hover previews and dotted note indicators.
  • Restore annotations from local storage or a custom backend.
  • Re-anchor highlights after content changes.
  • Render overlapping highlights through the CSS Custom Highlight API.
  • Adjust toolbar actions, keyboard shortcuts, labels, and placement.
  • Switch between light and dark themes or use a custom CSS theme.
  • Use the vanilla JavaScript engine, headless package, or React adapter.

How To Use It

Installation

Install penciled via NPM:

npm install penciled

Basic Usage

Create a container for the text that readers should annotate. Import the library and stylesheet after that container exists. Selecting text opens a floating toolbar, and saved annotations return from localStorage after a reload.

<article id="article">
  <h2>A Short Guide to CSS Grid</h2>
  <p>CSS Grid arranges content in rows and columns.</p>
  <p>Select any sentence to highlight it or attach a note.</p>
</article>
import { annotate } from 'penciled';
import 'penciled/styles.css';
const penciled = annotate('#article');

Direct Browser Import (CDN)

For a page with no build step, load the ES module from a CDN.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/penciled/dist/styles.css">
<article id="article">
  <p>Select this text and save a highlight.</p>
</article>
<script type="module">
  import { annotate } from 'https://cdn.jsdelivr.net/npm/penciled/+esm';
  annotate('#article');
</script>

Save Annotations for Each Article

Set documentId explicitly in single-page applications, especially when the displayed article changes but the URL does not. The identifier remains fixed for an instance’s lifetime.

const penciled = annotate('#article', {
  documentId: 'css-grid-introduction',
  noteIndicator: 'underline',
  notePreview: 'hover',
});

Save Notes to Your Own Backend

The storage option accepts asynchronous load and save methods. Your server must expose the endpoints and return an array of annotations for each document. A missing document should return an empty array. A failed request must reject the promise. penciled does not retry failed saves.

If loading from a custom adapter fails, the instance disables later saves to protect existing server data. For per-annotation REST endpoints, the adapter can additionally implement create, update, and delete. Those methods replace full-document save for their respective changes.

import { annotate } from 'penciled';
import 'penciled/styles.css';
const storage = {
  async load(documentId) {
    const response = await fetch(`/api/annotations/${encodeURIComponent(documentId)}`);
    if (response.status === 404) return [];
    if (!response.ok) throw new Error('Unable to load annotations');
    return response.json();
  },
  async save(documentId, annotations) {
    const response = await fetch(`/api/annotations/${encodeURIComponent(documentId)}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(annotations),
    });
    if (!response.ok) throw new Error('Unable to save annotations');
  },
};
const penciled = annotate('#article', {
  documentId: 'css-grid-introduction',
  storage,
  onError: (error) => console.error('Annotation storage failed', error),
});

Use Read-Only Highlights

readOnly locks the reader-facing controls while keeping annotations visible. The example reads any annotations previously stored for this document ID.

To load publisher annotations, use a custom storage adapter. To lock individual entries, set editable: false on those annotations. Instance methods can edit records even when readers cannot.

annotate('#article', {
  documentId: 'editorial-review',
  readOnly: true,
  noteIndicator: 'underline',
});

Manage and Export Annotations

Wait for ready before reading stored annotations. Use update() to change the returned records, since getAll() and other read methods return copies. Exported data includes orphaned annotations whose original text is no longer identifiable.

const penciled = annotate('#article', { documentId: 'css-grid-introduction' });
await penciled.ready;
const annotations = penciled.getAll();
if (annotations.length) {
  penciled.update(annotations[0].id, {
    color: 'green',
    note: 'Review this paragraph again.',
  });
}
const backup = penciled.export();
console.log(JSON.stringify(backup));
// Merge annotations from a previously exported JSON object.
penciled.import(backup);

Use penciled with React

import { Annotatable } from 'penciled/react';
import 'penciled/styles.css';
export function ReadingPage({ article }) {
  return (
    <Annotatable as="article" options={{ documentId: article.id }}>
      <h1>{article.title}</h1>
      <p>{article.summary}</p>
    </Annotatable>
  );
}

Create a Custom Toolbar with Headless Mode

penciled/headless handles anchoring, highlights, storage, and events, but does not create any floating controls. The example below adds a Highlight button that appears near a new text selection.

The data-penciled attribute keeps clicks on the button from dismissing the selection. Import the default stylesheet for highlight colors, or write your own ::highlight() rules.

import { annotate } from 'penciled/headless';
import 'penciled/styles.css';
const penciled = annotate('#article');
const button = document.createElement('button');
button.textContent = 'Highlight';
button.hidden = true;
button.style.position = 'fixed';
button.setAttribute('data-penciled', '');
document.body.append(button);
let selected = null;
penciled.on('select', (context) => {
  selected = context;
  if (!context || context.annotation || !context.editable) {
    button.hidden = true;
    return;
  }
  const rect = context.range.getBoundingClientRect();
  button.style.left = `${rect.left}px`;
  button.style.top = `${Math.max(8, rect.top - 40)}px`;
  button.hidden = false;
});
button.addEventListener('mousedown', (event) => event.preventDefault());
button.addEventListener('click', () => {
  if (selected?.editable && !selected.annotation) {
    penciled.create(selected.range, { color: 'yellow' });
  }
  button.hidden = true;
  getSelection()?.removeAllRanges();
});

All Configuration Options

Data and Storage

OptionType, default, and behavior
documentIdstring. Defaults to the page pathname plus the container ID when present. Determines which annotations are stored together. Multiple containers with no IDs receive unique suffixes.
storage'local' | 'memory' | AnnotationStorage. Default: 'local'. Local storage uses penciled:<documentId>; memory storage disappears on reload.
saveDelaynumber. Default: 300 milliseconds. Debounces storage writes; 0 saves each change immediately.
observeboolean. Default: true. Watches container content changes and re-anchors annotations. Set to false and call refresh() manually if necessary.

Colors and Appearance

OptionType, default, and behavior
colors(string | Color)[]. Default: ['yellow', 'green', 'blue', 'pink', 'purple']. Sets available highlight colors and their toolbar order.
defaultColorstring. Default: the first color in colors. Used for new annotations and for colors that have been removed from the palette.
theme'light' | 'dark' | 'auto'. Default: 'auto'. Controls the floating UI color scheme.
noteIndicator'underline' | false. Default: false. Displays a dotted underline on highlights with notes.
notePreview'hover' | false. Default: false. Shows a note when the mouse rests over its highlight.
placement{ toolbar?, editor?, preview? }. Default: { toolbar: 'auto', editor: 'bottom', preview: 'top' }. Configures popover positions.
rootElement | ShadowRoot. Default: document.body. Mounts the floating UI and its screen-reader live region in the selected root.

Interaction and Accessibility

The toolbar supports arrow-key navigation and Escape to close it. Annotation navigation shortcuts announce locations to screen readers. Keep the toolbar shortcut active with openOnSelect: false, unless another control can open it.

OptionType, default, and behavior
actions(BuiltInAction | Action)[]. Default: ['highlight', 'note', 'remove']. Controls toolbar buttons and their order. The optional built-in copy button copies selected text.
openOnSelectboolean. Default: true. With false, the keyboard toolbar shortcut opens the toolbar after selecting text.
readOnlyboolean. Default: false. Prevents readers from editing annotations. copy and custom actions can continue to appear.
maxNoteLengthnumber. No default limit. Restricts the length of notes entered through the editor.
shortcuts{ toolbar?, next?, previous? } | false. Default: { toolbar: 'Alt+A', next: 'Alt+]', previous: 'Alt+[' }. Overrides or disables keyboard shortcuts.
labelsPartial<Labels>. Default: English UI strings. Replaces visible labels and accessible names.

Event Callback Options

Each callback receives the event payload and can be registered during initialization or later through on().

OptionCalled with
onCreateNew Annotation.
onUpdateUpdated Annotation.
onDeleteRemoved Annotation.
onOrphanAnnotation that has lost a confident text match.
onLoadLoaded Annotation[].
onErrorStorage error (unknown).
onSelectSelectContext | null.

Instance Properties and Methods

annotate() returns an Annotator instance. Read methods return annotation copies.

Properties

PropertyDescription
documentIdRead-only storage identity for this instance.
containerRead-only DOM element being annotated.
readyRead-only Promise<void> that resolves after initial loading, including when loading fails.

Reading and Editing

MethodDescription
getAll()Returns all annotations in document order, including orphans.
get(id)Returns an annotation or undefined.
getRange(id)Returns the current DOM Range or null if unmatched.
annotationAt(x, y)Finds the annotation at viewport coordinates, or undefined.
create(range, init?)Highlights a DOM Range; accepts optional color, note, editable, and meta. Returns an annotation or undefined for empty text.
update(id, patch)Changes color, note, editable, or meta. Returns the new annotation or undefined for an unknown ID.
remove(id)Removes one annotation.
clear()Removes all annotations in the current document.
import(data, options?)Imports ExportData or Annotation[]. Uses mode: 'merge' by default. Set mode: 'replace' to remove existing entries absent from the import.
export()Returns { version: 1, documentId, annotations }.

Navigation and Lifecycle

MethodDescription
focus(id)Scrolls to an annotation and opens the toolbar in the default UI. Emits select in headless mode.
blur()Dismisses an open annotation or selection toolbar.
refresh()Re-anchors and repaints annotations after content changes.
setTheme(theme)Changes the default UI to 'light', 'dark', or 'auto'. Not available in headless mode.
on(event, listener)Registers a listener and returns an unsubscribe function.
destroy()Saves pending changes and removes the instance’s UI, highlights, observers, and listeners. Stored annotations are preserved.

Events

Listeners registered with on() use the event names without the on prefix.

EventFires when
createAn annotation is created.
updateAn annotation’s editable fields change.
deleteAn annotation is removed.
orphanA highlight loses its text anchor.
loadInitial stored annotations have loaded and been anchored.
errorLoading or saving fails.
selectA selection or existing annotation becomes active; null indicates dismissal.
const stopListening = penciled.on('create', (annotation) => {
  console.log(annotation.quote);
});
// Call when this listener is no longer needed.
stopListening();

Styling and Customization

CSS Custom Properties

VariablePurpose
--penciled-yellowYellow highlight color.
--penciled-greenGreen highlight color.
--penciled-blueBlue highlight color.
--penciled-pinkPink highlight color.
--penciled-purplePurple highlight color.
--penciled-activeActive annotation overlay.
--penciled-notedDecoration for highlights with notes.
--penciled-bgFloating toolbar and editor background.
--penciled-fgFloating toolbar and editor text.
--penciled-mutedPlaceholder text.
--penciled-borderBorders and dividers.
--penciled-hoverButton hover background.
--penciled-accentPrimary button background.
--penciled-accent-fgPrimary button text.
--penciled-ringKeyboard focus outline.
--penciled-radiusPopover corner radius.
--penciled-shadowPopover shadow.
--penciled-fontFont family.
--penciled-zFloating UI stacking level.
-root {
  --penciled-yellow: light-dark(#fef08a, #854d0e80);
  --penciled-radius: 8px;
  --penciled-accent: light-dark(#4f46e5, #a5b4fc);
  --penciled-accent-fg: light-dark(#fff, #1e1b4b);
}

Public CSS Attribute Selectors

SelectorApplies to
[data-penciled]Library-owned UI elements, and custom UI elements marked for selection exclusion.
[data-penciled-popover]Floating toolbar and note preview cards.
[data-penciled-popover='toolbar']Annotation toolbar, note display, and editor card.
[data-penciled-popover='preview']Hover note preview card.
[data-penciled-side]Actual popover placement, such as top or bottom-start.
[data-penciled-theme]Effective UI setting: light, dark, or auto.
[data-penciled-swatch]Color selection button.
[data-penciled-action='<id>']Action button IDs: note, copy, remove, color-<name>, or a custom ID.
[data-penciled-note]Note display inside a toolbar or preview.
[data-penciled-editor]Note editing form.
[data-penciled-primary]Primary Save button.

Highlight Selectors

The CSS Custom Highlight API paints ::highlight(penciled-<color>) for each color, ::highlight(penciled-<color>-noted) for highlighted notes when the underline indicator is enabled, and ::highlight(penciled-active) for the active annotation.

Only CSS properties permitted by ::highlight() apply. Borders, padding, and border radius cannot style highlighted ranges.

-:highlight(penciled-yellow),
-:highlight(penciled-yellow-noted) {
  background-color: #fde04780;
}
-:highlight(penciled-yellow-noted) {
  text-decoration: underline dotted 2px;
}

Headless Option Scope

The headless package accepts documentId, storage, saveDelay, observe, all seven event callbacks, colors (string names only), defaultColor, readOnly, and noteIndicator. UI-specific options, including theme, actions, labels, placement, shortcuts, maxNoteLength, and notePreview, are unavailable in headless mode.

Alternatives & Related Resources

You Might Be Interested In:


Leave a Reply