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'
})
the deadline is March 14

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
})
flagged for review
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.

Set 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.

this sentence runs long
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 }]
}
spelled about right
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.

One call underlines the first target, then the second, then the third — drawn in order, hidden 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().

OptionTypeDefault

Annotation

What annotate() returns. One handle, however many elements matched — its methods drive them all together.

MemberType

Spine

One stroke, as returned by a shape. Only points is required.

KeyType

Browser support

Chrome and Edge 113, Firefox 115, and Safari 17.4 or later.