notation
hand-drawn annotations for the web
Underline a phrase, box a warning, circle the thing people keep missing. Each annotation is a rough ink stroke animated over an ordinary DOM element, and it re-draws itself when the text reflows. There's no component to wrap and nothing to install around it — any framework works, so does none. Zero dependencies.
Installation
npm install @shardsui/notation Coding agents can install a SKILL.md that teaches them the API, then take instructions
like /notation underline the page title.
npx skills add abdrizik/notation Usage
One function. annotate() takes a target and options, and gives back an Annotation.
Nothing appears until you call show().
import { annotate } from '@shardsui/notation'
const a = annotate('#title', {
type: 'circle',
color: 'crimson',
padding: 8
})
a.show() The target can be an element, a selector, or an array mixing both. A selector marks every
element it matches, so annotate('.term', { type: 'underline' }) underlines all of them.
If nothing matches, you get a console warning and an annotation that quietly does nothing.
show() and hide() return the annotation, so calls chain. When you need to know the ink
has landed, await finished.
Types
Pick a type — it's the only required option. Eight are built in.
annotate('#title', {
type: 'underline'
}) Options
Everything else has a sensible default. Click through — each option shows the exact call that draws it.
annotate('#title', {
type: 'underline',
color: 'crimson',
strokeWidth: 5
}) const a = annotate('#el', {
type: 'underline',
color: 'var(--color-ink)',
})
a.show() Multiline
Text that wraps gets one mark per line. Resize the window and the strokes follow the new line breaks on their own.
multiline and every line box in this wrapped paragraph gets a fresh path of its own, re-measured whenever
the text reflows around it rather than one stroke stretched across the whole block.const a = annotate('#paragraph', {
type: 'underline',
multiline: true
})
a.show() Hiding
hide() runs the stroke backwards until the ink is gone. Check showing to know which way
to go, and if you call show() mid-retraction, the pen picks up right where it stopped.
const flag = annotate('#clause', { type: 'box' })
control.onclick = () => (flag.showing ? flag.hide() : flag.show()) Custom shapes
When the built-in types aren't enough, hand type a function. It gets the measured box and
returns an array of Spines — one per stroke. That's the same
contract the built-in types use, so anything they can do, yours can too. One thing to
remember: the box isn't at the origin, so build coordinates from rect.x and rect.y.
annotate('#title', {
type: underline
})
function underline(rect) {
const y = rect.y + rect.h
const line = [
[rect.x, y],
[rect.x + rect.w, y]
]
return [{ points: line }]
} annotate('#el', { type: squiggle }).show()
function squiggle(rect: Rect, { padding }: ShapeOptions): Spine[] {
const y = rect.y + rect.h + padding.bottom
const waves = Math.round(rect.w / 26)
const points: Point[] = []
for (let i = 0; i <= waves * 8; i++) {
const t = i / (waves * 8)
points.push([rect.x + rect.w * t, y + Math.sin(t * waves * Math.PI * 2) * 3])
}
return [{ points }]
} Custom shapes get the whole bounding box by default; set multiline: true to run once per
line instead. The second argument carries the rest of the options, with padding already
resolved to { top, right, bottom, left }. And if a shape needs parameters of its own,
just return it from a function.
Multiple targets
Pass an array of targets — elements, selectors, any mix — and one call marks them all.
You hold a single handle: the strokes draw in order, each waiting for the previous one to
land, and hide() retracts them together.
const a = annotate(['#first', '#second', '#third'], { type: 'underline' })
a.show() When each target deserves its own look, pass [target, options] pairs instead. Same
single handle, same choreography.
const a = annotate([
['#title', { type: 'circle' }],
['#term', { type: 'highlight' }]
])
a.show() Frameworks
The recipe is the same everywhere: draw when the element mounts, remove() when it
unmounts.
import { annotate } from '@shardsui/notation'
export function Title() {
return (
<h1
ref={(node) => {
const a = annotate(node, { type: 'underline' })
a.show()
return () => a.remove()
}}
>
Hand-drawn annotations
</h1>
)
} API reference
AnnotationOptions
The second argument to annotate().
Annotation
What annotate() returns. One handle, however many elements matched — its methods drive
them all together.
Spine
One stroke, as returned by a shape. Only points is required.
Browser support
Chrome and Edge 113, Firefox 115, and Safari 17.4 or later.