npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

brushmark

v0.1.1

Published

Animated painterly brush-stroke annotations for text on the web. p5.brush rendering, rough-notation ergonomics, GSAP timing.

Readme

brushmark

Animated, hand-painted annotations for text on the web. brushmark draws real brush strokes — graphite, marker, watercolor, spray, charcoal — over ordinary DOM text, animated with a GSAP-shaped timeline API (built-in zero-dependency engine, or your own GSAP / Anime.js), while the text itself stays selectable, accessible, CSS-responsive text.

It combines three engines:

  • p5.brush (standalone build, no p5.js) renders the painterly strokes on a single shared WebGL2 canvas.
  • rough-notation's annotation model: per-line rect tracking, adjacent absolutely-positioned overlays, ResizeObserver-driven re-rendering.
  • Native word geometry — per-word boxes are read straight from the rendered layout with a DOM Range, for word-level stagger and natural pressure variation.
  • A swappable animation engine drives all timing behind one GSAP-shaped facade: seconds, "power2.out"-style eases, timelines, repeat/yoyo, scrubbing. Ships with a ~3 KB built-in engine; adapters for GSAP and Anime.js v4 are one import away.

See it

Real animated brush strokes drawn over live DOM text (the text stays selectable and responsive — these are just the painted overlays revealing in):

The live demo has a Recipes section pairing every effect with the exact, copy-pasteable annotate() call that produced it — see Demos.

Install

npm i brushmark            # zero animation dependencies (built-in engine)
npm i brushmark gsap       # if you want GSAP driving the timelines
npm i brushmark animejs    # if you want Anime.js v4

gsap and animejs are optional peers — install one only if you opt into that engine. react is an optional peer for the brushmark/react entry.

Animation engines

By default brushmark animates with its built-in engine: no dependencies, exact scrubbing, repeat/yoyo, the common GSAP ease names (power1–4, sine, expo, circ, back, elastic, bounce, plus custom functions). To hand timing to a real animation library instead:

import { setAnimationEngine } from "brushmark";

// GSAP — annotation timelines become REAL gsap.core.Timeline instances:
import { gsapEngine } from "brushmark/gsap";
setAnimationEngine(gsapEngine);

// or Anime.js v4:
import { animeEngine } from "brushmark/anime";
setAnimationEngine(animeEngine);

The engine is captured when an annotation is created, so call setAnimationEngine() before annotate(). Adapters are separate entry points: whichever you don't import costs zero bytes. The config format (seconds, ease strings, repeat/yoyo, timelineVars) is identical across engines; engine-specific timelineVars keys (like scrollTrigger) only take effect on their engine.

Quick start (vanilla)

import { annotate } from "brushmark";

const a = annotate(document.querySelector("#phrase"), {
  type: "highlight",                       // or underline | box | circle | contour |
                                           // strike-through | crossed-off | bracket
  brush: { name: "marker", color: "#f2c94c" },
  duration: 1.2,
  ease: "power2.out",
});
await a.show();       // paints in
await a.hide();       // un-draws (reverse) — also 'fade' | 'instant'
a.remove();           // cleanup: overlay, observers, tweens

Sequencing

import { annotate, annotationGroup } from "brushmark";

const group = annotationGroup([a1, a2, a3], { overlap: 0.15 });
group.show();                    // plays children one after another
group.timeline.progress(0.5);    // or scrub the master timeline

Adopting the timeline (ScrollTrigger etc.)

Every annotation owns a paused timeline (BrushTimeline). On the gsap engine it is a real gsap.core.Timeline, so all GSAP interop works natively:

import { setAnimationEngine } from "brushmark";
import { gsapEngine } from "brushmark/gsap";
setAnimationEngine(gsapEngine);

const a = annotate(el, { type: "underline", timelineVars: {
  scrollTrigger: { trigger: el, scrub: true },   // register ScrollTrigger yourself
}});
a.prepare(); // build without playing — timeline gets its real duration

// or nest it into your own timeline:
master.add(a.timeline.paused(false), "+=0.2");

If you previously typed callbacks as (tl: gsap.core.Timeline) => void, switch to the exported BrushTimeline type (or cast — on the gsap engine the object really is a gsap timeline).

React

import { BrushAnnotation, BrushAnnotationGroup, useBrushAnnotation } from "brushmark/react";

<BrushAnnotation type="circle" brush={{ name: "2B", color: "#c93030" }} show={visible}>
  important words
</BrushAnnotation>

<BrushAnnotationGroup overlap={0.1}>
  ...children sequence themselves in render order...
</BrushAnnotationGroup>

const { ref, show, hide, annotation } = useBrushAnnotation<HTMLSpanElement>({
  type: "underline",
  brush: { name: "pen" },
});

onTimeline={(tl) => ...} hands you the annotation's timeline for useGSAP-style composition (add it to your timelines; don't kill it — the component owns its lifecycle).

Options

annotate(el, {
  type: "underline",
  brush: {
    name: "2B",            // listBrushes(): pen, rotring, 2B, HB, 2H, cpencil,
                           // pastel, crayon, charcoal, spray, marker, + custom
    color: "#c93030",
    weight: 1.5,           // strokeWeight multiplier; 0 = fill/hatch only, no outline
    scaleWithFont: true,   // weight follows the element's font size (default)
    pressure: [1.2, 0.8],  // constant | [start, end] | (t) => number
    wiggle: 1,             // hand-tremor wobble
    field: "waves",        // p5.brush vector field distortion
    fill: { color, opacity, bleed, texture, border },   // watercolor (closed shapes)
    hatch: { distance: 4, angle: -41, rand: 0.1 },      // hatching (closed shapes)
  },
  padding: 4,              // number | [v,h] | [t,r,b,l]
  multiline: true,         // annotate each wrapped line (default)

  // animation (GSAP conventions, seconds — same on every engine)
  animate: true,
  duration: 0.8,
  delay: 0,
  ease: "power2.out",      // GSAP-style ease string or (t) => number
  direction: "ltr",        // 'rtl' | 'center-out'
  stagger: { by: "word" }, // 'line' | 'word' | 'stroke'; each / overlap
  iterations: 2,           // extra jittered passes, rough-notation style
  repeat: -1, yoyo: true,  // loop
  timelineVars: { ... },   // merged into the engine's timeline vars
  seed: 42,                // deterministic art (defaults to a stable hash)
  onResize: "snap",        // 'snap' keeps progress across reflows | 'replay'

  // type-specific
  highlightStyle: "marker",            // or 'watercolor'
  brackets: ["left", "right"],
  contour: { inflate: 6, roundness: 0.8, irregularity: 1, singleLine: "auto" },
});

Custom brushes pass straight through to p5.brush:

import { registerBrush, listBrushes } from "brushmark";
registerBrush("my-ink", { type: "custom", weight: 0.9, vibration: 0.14, /* … */ });

How it works

  • One WebGL context total. Every annotation's finished art is painted once on a shared off-DOM WebGL2 canvas, keyed from its white paper background to true transparency, and snapshotted to a cheap per-annotation 2D canvas.
  • Reveal is a pure function of progress. Each frame, the overlay composites the pre-rendered art through a dash-offset mask that grows along the same polyline the brush painted (sweep/fade for watercolor and hatch). That's why scrubbing, reverse(), repeat, timeScale and ScrollTrigger scrub all work exactly.
  • Overlays are anchored inside the annotated element. The element is made position: relative and the canvas lives inside it, so strokes travel with the text through any reflow — content changes elsewhere on the page, column resizes, scroll containers, transformed ancestors — with zero position-tracking JavaScript. Highlights sit under the glyphs (but above the element's own background) via a negative z-index child + isolation.
  • Responsive by re-measurement. ResizeObserver + window resize + font-load events re-measure the line rects (via Range.getClientRects(), overlays excluded); if the element-relative geometry changed, brushmark rebuilds with the same seed, re-paints, and snaps the timeline back to its progress — mid-animation reflows continue seamlessly.
  • Word geometry from the rendered layout. Per-word x/width inside each measured line comes from Range.getClientRects() on the words themselves — layout reads only, no DOM writes, so no forced reflow. Kerning, letter-spacing, text-transform, nested markup and hyphenation are all reflected exactly; if the words can't be grouped one row per measured line (e.g. multiline: false while the text wraps), brushmark silently falls back to line-level animation.

Caveats

  • The annotated element gains position: relative (if static) and a child <canvas class="brushmark-overlay">; both are restored/removed by remove(). If the element itself has overflow: hidden/auto, strokes that extend past its box (padding, contour inflation) will be clipped.
  • Painted colors are extracted from a white-paper render; very light strokes on dark backgrounds will look like real paint would on dark paper — dim.
  • prefers-reduced-motion renders the final state instantly.

Demos

npm run dev
# → /vanilla/index.html  (recipes: code + live preview, types, brush gallery,
#                         contour, animation lab, responsive, sequencing/scrub,
#                         watercolor, stress test;
#                         engine switcher: ?engine=builtin|gsap|anime)
# → /react/index.html    (components, hooks, groups, timeline adoption; StrictMode)

The Recipes section on the vanilla page pairs each effect with the exact annotate() call that paints it — copy the snippet, drop in your own element.

The README GIFs above are generated from those same recipes by scripts/capture.mjs (requires the dev server running plus ffmpeg and a dev-only playwright install):

npm run dev &                                   # serve the capture page
BASE=http://localhost:5173 node scripts/capture.mjs

License

MIT. Bundles p5.brush (MIT). Optional engine peers: GSAP (Webflow license — see gsap.com/licensing) and Anime.js (MIT).