v0.2.0

Scrawl

7.9 KB gzipped · no deps

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.


try it

Turn the knobs

Shape

Ink

Thickness

3px

Passes

2

Jitter

1

Draw on

650ms

Kept the good bits, threw out the parts that were only there to look busy, and shipped it on a Tuesday.

Markup


      

all eleven

The shape sheet

Each one is drawn twice by default, which stops it reading as a vector graphic. Scroll them into view to see the reveal.


wrapping

Across line breaks

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.


arrows

From here to there

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.

browser

Sends the order

server

Checks the stock

db.json

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>

Notes in the margin

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>

install

Use it

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();

Options

AttributeDefaultWhat it does
data-scrawlunderlineShape name. One of the eleven above, or arrow or curl.
data-scrawl-colorinheritedAny CSS colour. Falls back to the element's own text colour.
data-scrawl-thicknessfont-size × 0.085, min 1.4pxStroke width in px, written as a plain number. Scales with font size if you leave it alone.
data-scrawl-passes2How many times the pen goes over the whole figure. 1 is clean, 3 is scratchy.
data-scrawl-jitter10 is ruler straight. Above 1.5 gets messy quickly.
data-scrawl-opacity1Marker defaults to 0.32 so it does not bury the text.
data-scrawl-animatetrueDraw-on reveal, triggered by IntersectionObserver.
data-scrawl-duration650Milliseconds per stroke.
data-scrawl-delay0Milliseconds before the first stroke. Use it to sequence a paragraph.
data-scrawl-stagger0.35Gap between strokes, as a fraction of duration.
data-scrawl-directionrightWhich end the pen starts from. Arrows always start from their own element.
data-scrawl-seedthe textChange it to get a different wobble for the same words.
data-scrawl-frontfalse, true for arrowstrue paints over the glyphs instead of behind them.
data-scrawl-repeatfalseRedraw every time it scrolls back into view.
data-scrawl-threshold0.2How much of the element has to be visible before it draws.
data-scrawl-respect-reduced-motiontruefalse draws even when the OS asks for reduced motion.
data-scrawl-tononeArrows only. A selector for the element to point at.
data-scrawl-headendArrows only. end, start, both or none.
data-scrawl-gapthickness × 2.5, min 4pxArrows only. Px left clear between each end and its element.
data-scrawl-withinshared ancestorArrows only. A selector for the element the SVG goes in.

Worth knowing

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.