It draws wobbly SVG strokes behind your text, one per line box, so wrapping just works. Every stroke is seeded from the text itself, so it looks hand drawn but never changes between reloads. Eleven shapes, arrows, no dependencies, and it respects reduced motion.
Every marketing site wants the pen-on-paper underline and every one of them builds it again from scratch. This is that effect on its own, MIT, with no site builder attached. There is also a no JavaScript version if you only need static strokes, and the source is on GitHub.
Kept the good bits, threw out the parts that were only there to look busy, and shipped it on a Tuesday.
Each one is drawn twice by default, which stops it reading as a vector graphic. Scroll them into view to see the reveal.
Strokes are measured per line box, so a run that wraps gets one stroke per line rather than a single stroke stretched across the gap. Resize the window and they redraw.
The hard part was never the drawing, it was working out where the text actually sits once the browser has finished laying it out, because a span that wraps is not one rectangle, it is several, and the naive approach draws one enormous stroke through the middle of your paragraph which nobody wants.
Point an arrow at another element with data-scrawl-to and it leaves the
middle of one facing side and lands square on the other. Side by side it runs across, stacked it runs
down, and it redraws when the layout changes. Stack these and watch.
Sends the order
Checks the stock
Writes it down
<div id="browser" data-scrawl="arrow" data-scrawl-to="#server">…</div> <div id="server" data-scrawl="arrow" data-scrawl-to="#db">…</div> <div id="db">…</div>
A curl is an arrow with a loop in it just after it sets off, for an aside rather than a
diagram. The sticky note is plain CSS, the .scrawl-note class from
scrawl-note.css, and the curl takes its colour from the note's text.
const receipt = await charge(cart);
await email(receipt);
markPaid(cart);
return receipt;
Nothing else
happens until
this comes back
<link rel="stylesheet" href="scrawl-note.css"> <p class="scrawl-note" data-scrawl="curl" data-scrawl-to="#slow-call"> Nothing else happens until this comes back </p>
Drop the script in, add a data-scrawl attribute, done. It self initialises on DOM ready.
<script src="scrawl.js"></script> <p>Kept the good bits, threw out <span data-scrawl="scribble" data-scrawl-color="#d2372a">the busy parts</span>. </p>
Or drive it from script:
Scrawl.annotate('#headline', { type: 'circle', color: '#1f8a5b', thickness: 3, animate: true }).show();
| Attribute | Default | What it does |
|---|---|---|
| data-scrawl | underline | Shape name. One of the eleven above, or arrow or curl. |
| data-scrawl-color | inherited | Any CSS colour. Falls back to the element's own text colour. |
| data-scrawl-thickness | font-size × 0.085, min 1.4px | Stroke width in px, written as a plain number. Scales with font size if you leave it alone. |
| data-scrawl-passes | 2 | How many times the pen goes over the whole figure. 1 is clean, 3 is scratchy. |
| data-scrawl-jitter | 1 | 0 is ruler straight. Above 1.5 gets messy quickly. |
| data-scrawl-opacity | 1 | Marker defaults to 0.32 so it does not bury the text. |
| data-scrawl-animate | true | Draw-on reveal, triggered by IntersectionObserver. |
| data-scrawl-duration | 650 | Milliseconds per stroke. |
| data-scrawl-delay | 0 | Milliseconds before the first stroke. Use it to sequence a paragraph. |
| data-scrawl-stagger | 0.35 | Gap between strokes, as a fraction of duration. |
| data-scrawl-direction | right | Which end the pen starts from. Arrows always start from their own element. |
| data-scrawl-seed | the text | Change it to get a different wobble for the same words. |
| data-scrawl-front | false, true for arrows | true paints over the glyphs instead of behind them. |
| data-scrawl-repeat | false | Redraw every time it scrolls back into view. |
| data-scrawl-threshold | 0.2 | How much of the element has to be visible before it draws. |
| data-scrawl-respect-reduced-motion | true | false draws even when the OS asks for reduced motion. |
| data-scrawl-to | none | Arrows only. A selector for the element to point at. |
| data-scrawl-head | end | Arrows only. end, start, both or none. |
| data-scrawl-gap | thickness × 2.5, min 4px | Arrows only. Px left clear between each end and its element. |
| data-scrawl-within | shared ancestor | Arrows only. A selector for the element the SVG goes in. |
The SVG is appended to the annotated element's parent at z-index: -1, and the parent
gets position: relative and isolation: isolate if it did not have them. That
keeps everything aligned through scrolling and scaled ancestors without any coordinate maths, and the
isolation stops the negative z-index escaping to the root stacking context. If the annotated element
itself has a background, or a later sibling paints over the stroke, you will want
data-scrawl-front="true" instead. Redraws are debounced through a
ResizeObserver on the parent plus document.fonts.ready, so a late web font
does not leave strokes stranded under the old metrics.
An arrow's SVG goes in the nearest ancestor holding both ends instead, and it also redraws when
either end changes size. If that ancestor is something big, like <body>, making it
relative can move anything absolutely positioned against an element further up, so name a closer one
with data-scrawl-within.