notation

hand-drawn annotations for the web

Underlines, boxes, circles, highlights, strike-throughs, cross-offs, brackets and arrows, or a shape you write yourself. Each one is an animated SVG stroke over any element, re-measured when the text reflows. It takes a DOM element rather than a component, so it works in any framework, or none. Zero dependencies.

Installation

npm install @shardsui/notation

Coding agents can install a SKILL.md that teaches them the API, and then take instructions like /notation underline the page title.

npx skills add abdrizik/notation

Usage

annotate() returns an Annotation. Nothing is drawn until you call show().

import { annotate } from '@shardsui/notation'

const a = annotate('#title', {
  type: 'circle',
  color: 'crimson',
  padding: [6, 10]
})

a.show()

The target is an element or a selector string. A selector matches every element it finds, so annotate('.term', { type: 'underline' }) underlines all of them. A selector that matches nothing warns and returns an annotation whose methods do nothing.

show(), hide() and update() return the annotation, so calls chain. finished settles when the stroke lands.

Types

type is the only required option. Eight are built in.

annotate('#title', {
  type: 'underline'
})
the deadline is March 14

Options

Each option below shows the 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

Wrapped text gets one mark per line box. Resize the window and the strokes follow the new wrap.

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.
annotate('#paragraph', {
  type: 'underline',
  multiline: true
}).show()

Hiding

hide() draws the stroke backwards until nothing is left. Read showing to pick the direction. A second show() during the retraction picks the pen up where it left off.

this sentence runs long
const flag = annotate('#clause', { type: 'box' })

control.onclick = () => (flag.showing ? flag.hide() : flag.show()) 

Custom shapes

type also takes a function. It receives the measured box and returns an array of strokes, each one a list of points or a Spine. The built-in types use the same contract. Build coordinates from rect.x and rect.y; the box is not at the origin.

annotate('#title', {
  type: underline 
})

function underline(rect) {
  const y = rect.y + rect.h
  const line = [
    [rect.x, y],
    [rect.x + rect.w, y]
  ]
  return [line]
}
spelled about right
annotate('#el', { type: squiggle }).show()

function squiggle(rect: Rect, { padding }: ShapeOptions): Point[][] {
  const y = rect.y + rect.h + padding[2]
  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]
}

multiline is false for custom shapes, so your function gets the whole bounding box. Set it to true to run once per line. The second argument carries the rest of the options, with padding resolved to four numbers. To give a shape parameters, return it from a function.

Multiple targets

One call can cover several targets. Pass [target, config] pairs; the strokes draw in the order you list them, each waiting for the one before it. hide() retracts them together.

annotate('.term', { type: 'underline' })
  .show()

target  → circled   type → banded   call → underlined
const a = annotate([
  ['#target', { type: 'circle', padding: [2, 6] }],
  ['#type', { type: 'highlight' }],
  ['#call', { type: 'underline' }],
  ['#snippet', { type: 'box' }]
])

a.show()

Frameworks

Draw when the element mounts, and call 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().

OptionTypeDefaultDetails
typeAnnotationType | Shape—
colorstring'currentColor'
strokeWidthnumber1.5
paddingPadding5
iterationsnumber1
rtlbooleanfalse
wobblenumber1
durationnumber800
delaynumber0
easingstringpen-paced linear()
sideSide | Side[]—
multilinebooleansee description
seednumberrandom

Annotation

One handle, however many elements the call matched. Its methods drive all of them together.

MemberTypeDetails
showingboolean
finishedPromise<void>
update(options)(options) => Annotation
show()() => Annotation
hide()() => Annotation
remove()() => void

Spine

A stroke can be a bare points array. Use a Spine when it needs one of the other keys.

KeyTypeDetails
points[number, number][]
closedboolean
overshootboolean
widthnumber

Browser support

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