
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
| Option | Type, default, and behavior |
|---|---|
documentId | string. 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. |
saveDelay | number. Default: 300 milliseconds. Debounces storage writes; 0 saves each change immediately. |
observe | boolean. Default: true. Watches container content changes and re-anchors annotations. Set to false and call refresh() manually if necessary. |
Colors and Appearance
| Option | Type, default, and behavior |
|---|---|
colors | (string | Color)[]. Default: ['yellow', 'green', 'blue', 'pink', 'purple']. Sets available highlight colors and their toolbar order. |
defaultColor | string. 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. |
root | Element | 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.
| Option | Type, 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. |
openOnSelect | boolean. Default: true. With false, the keyboard toolbar shortcut opens the toolbar after selecting text. |
readOnly | boolean. Default: false. Prevents readers from editing annotations. copy and custom actions can continue to appear. |
maxNoteLength | number. 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. |
labels | Partial<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().
| Option | Called with |
|---|---|
onCreate | New Annotation. |
onUpdate | Updated Annotation. |
onDelete | Removed Annotation. |
onOrphan | Annotation that has lost a confident text match. |
onLoad | Loaded Annotation[]. |
onError | Storage error (unknown). |
onSelect | SelectContext | null. |
Instance Properties and Methods
annotate() returns an Annotator instance. Read methods return annotation copies.
Properties
| Property | Description |
|---|---|
documentId | Read-only storage identity for this instance. |
container | Read-only DOM element being annotated. |
ready | Read-only Promise<void> that resolves after initial loading, including when loading fails. |
Reading and Editing
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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.
| Event | Fires when |
|---|---|
create | An annotation is created. |
update | An annotation’s editable fields change. |
delete | An annotation is removed. |
orphan | A highlight loses its text anchor. |
load | Initial stored annotations have loaded and been anchored. |
error | Loading or saving fails. |
select | A 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
| Variable | Purpose |
|---|---|
--penciled-yellow | Yellow highlight color. |
--penciled-green | Green highlight color. |
--penciled-blue | Blue highlight color. |
--penciled-pink | Pink highlight color. |
--penciled-purple | Purple highlight color. |
--penciled-active | Active annotation overlay. |
--penciled-noted | Decoration for highlights with notes. |
--penciled-bg | Floating toolbar and editor background. |
--penciled-fg | Floating toolbar and editor text. |
--penciled-muted | Placeholder text. |
--penciled-border | Borders and dividers. |
--penciled-hover | Button hover background. |
--penciled-accent | Primary button background. |
--penciled-accent-fg | Primary button text. |
--penciled-ring | Keyboard focus outline. |
--penciled-radius | Popover corner radius. |
--penciled-shadow | Popover shadow. |
--penciled-font | Font family. |
--penciled-z | Floating 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
| Selector | Applies 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
- Interactive Text Annotation and Highlighting with Recogito Text Annotator
- Text Annotation And Highlighting Library – Annotate.js
- Underline/Highlight/Strike Text With The textAnnotator Library
- Hightlight Content Selection For The Web – Web Highlighter
- Animated Handdrawn Text Annotation Library – Rough Notation







