Catalog / Recipe
Draw on
The strokes of an inline SVG are drawn in once, on first view, at a plotter's even pace, and nothing is left behind.
- Family
- generative mark
- Status
- lab
- Used for
- Section introFigureHeroPage load
- Size
- 1,093 of 1,200 bytes, gzipped
mount(element)A stroke can be given a dash as long as itself and that dash slid out
of view; animating the offset back to zero draws the line. This recipe
does that to every stroke inside an element’s inline SVGs, once, at a
linear pace, and only inside the animation: nothing is written to an
element before, during or after. Mounted on a mark from flow-mark or
contour-mark it draws every line at one pen speed; mounted on a
hand-drawn diagram it draws the diagram.
Use
import { mount } from './draw-on';
mount(document.querySelector('.figure'));
Every path, line, polyline, polygon, circle, ellipse and
rect inside every svg in the element is a stroke. One drawing a
page: a listing of drawn-on marks is a dozen animations at once, and
a diagram that is evidence is wanted at once, not drawn.
How it moves
The dash. A generated mark is one path of many lines, and a dash
pattern restarts at every subpath. The generator writes the length of
its longest line into data-longest; the dash is that long, so every
line draws at the same speed and the short ones finish first, which is
what a pen does. Any other stroke is measured with getTotalLength().
A stroke with neither is skipped.
The pace. Linear, and slow: a plotter does not ease. mode 0,
pen, gives each stroke a duration in proportion to its length; mode
1, together, gives every stroke the whole duration. stagger sets
each stroke’s start after the one before it, in document order.
When. trigger 0 starts the first time the element is on screen,
through an intersection observer at a fifth visible, or at once where
there is none; trigger 1 starts on mount. The animation is filled
backwards, so during delay the strokes are blank; it is not filled
forwards, so when it ends the stroke is as it is styled.
Options
| Option | Default | Meaning |
|---|---|---|
duration |
1600 | How long the longest stroke takes, in ms |
delay |
0 | A pause before the first stroke, in ms |
stagger |
120 | Between one stroke’s start and the next, in ms |
mode |
0 | Pen (one speed), or together |
trigger |
0 | In view, or on mount |
Versions
| Version | What it is |
|---|---|
| Pen | The defaults |
| Together | Every stroke takes the whole duration |
| Quick | 700 ms, a short stagger |
| Staggered | 400 ms between strokes |
| On load | Starts on mount |
Where it must not go
- A listing. Many marks at once are many animations at once.
- A diagram the reader came for. Evidence is shown, not drawn.
- A second time. Once, on first view; never a loop.
- A stroke with a
pathLengthattribute. The dash is measured in that attribute’s units and will not match.
What it does when things are missing
The rest state is the finished drawing, and every failure path is the
rest state: without animate, under reduced motion (decided through
matchMedia, once, at mount), without script, and if animate
throws. destroy() cancels what is running, which shows the drawing
finished, and disconnects the observer. The dash is never written to
the element, so nothing can be left behind.
Learned while building it
- Backwards fill, never forwards. A forwards fill would hold the dash on the element for ever; without it, the end of the animation is the element’s own style, which is the drawing.
- The generator knows the lengths. Measuring a path of a hundred subpaths gives their sum, which is the wrong dash for every one of them. The number has to come from where the lines were made.
/**
* draw-on
*
* The strokes of an inline SVG drawn in, once, on first view, and
* nothing left behind. Each stroke is given a dash as long as itself,
* slid out of view, and the offset is animated back to zero at a
* linear pace: the plotter's motion. The dash exists only inside the
* animation. Before it, during a delay, and after it, the element is
* as it is styled, so a browser without `animate`, a reader with
* reduced motion, and a page without script all see the finished
* drawing from the first frame.
*
* Copy this file. It imports nothing.
*
* import { mount } from './draw-on';
* mount(document.querySelector('.figure'));
*
* Every `path`, `line`, `polyline`, `polygon`, `circle`, `ellipse` and
* `rect` inside every `svg` in the element is a stroke. A mark made by
* `flow-mark` or `contour-mark` is one path of many lines and carries
* `data-longest`, the length of its longest line, which sets the dash
* so every line draws at one pen speed and the short ones finish
* first. Any other stroke is measured with `getTotalLength()`. One
* drawing a page: a listing of drawn-on marks is a dozen animations.
*/
export interface Options {
/** How long the longest stroke takes to draw, in ms. Under 600 is a flick; over 4,000 the reader waits for a picture. */
duration: number;
/** A pause before the first stroke, in ms. The drawing is blank during it. */
delay: number;
/** Between one stroke's start and the next, in document order, in ms. */
stagger: number;
/** 0 pen: every stroke draws at one speed and short ones finish first. 1 together: every stroke takes the whole duration. */
mode: number;
/** 0 when the element first comes on screen; 1 on mount. */
trigger: number;
}
export const DEFAULTS: Options = {
duration: 1600,
delay: 0,
stagger: 120,
mode: 0,
trigger: 0,
};
/**
* Named ways of drawing. The first is the defaults. `mount(element, PRESETS.Quick)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Pen: {},
Together: { mode: 1 },
Quick: { duration: 700, stagger: 60 },
Staggered: { stagger: 400 },
'On load': { trigger: 1 },
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
export interface Step {
duration: number;
delay: number;
}
const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const STROKES = 'path,line,polyline,polygon,circle,ellipse,rect';
const LIMITS: Record<string, [number, number]> = { duration: [100, 10000], delay: [0, 10000], stagger: [0, 5000], mode: [0, 1], trigger: [0, 1] };
/** Options clamped to their limits; anything that is not a finite number falls back to the base. */
export function mergeOptions(base: Options, partial: Partial<Options>): Options {
const out: Record<string, number> = {};
for (const [key, [low, high]] of Object.entries(LIMITS)) {
const given = partial[key as keyof Options];
const value = Math.min(high, Math.max(low, typeof given === 'number' && Number.isFinite(given) ? given : base[key as keyof Options]));
out[key] = high === 1 ? Math.round(value) : value;
}
return out as unknown as Options;
}
/** Every stroke inside every svg in the element, or in the element itself if it is one, in document order. */
export function strokesOf(root: Element): Element[] {
const svgs = root.matches('svg') ? [root] : [...root.querySelectorAll('svg')];
return svgs.flatMap((svg) => [...svg.querySelectorAll(STROKES)]);
}
/** The dash a stroke needs: the generator's longest line where it wrote one, else the stroke's own length, else nothing. */
export function lengthOf(stroke: Element): number | null {
const longest = Number(stroke.getAttribute('data-longest'));
if (Number.isFinite(longest) && longest > 0) return longest;
const measure = (stroke as { getTotalLength?: () => number }).getTotalLength;
if (typeof measure !== 'function') return null;
try {
const length = measure.call(stroke);
return Number.isFinite(length) && length > 0 ? length : null;
} catch {
return null;
}
}
/** How long each stroke takes and when it starts: one pen speed, or all together, and a stagger in document order. */
export function planFor(lengths: number[], options: Options): Step[] {
const longest = Math.max(0, ...lengths);
return lengths.map((length, index) => ({
duration: options.mode === 1 || longest === 0 ? options.duration : Math.max(1, (options.duration * length) / longest),
delay: options.delay + index * options.stagger,
}));
}
/**
* Draws the strokes of the element's svgs in, once. Nothing is written
* to any element: the dash lives in the animation, with a backwards
* fill for the delay, and `destroy()` cancels what is running, which
* shows the drawing finished.
*/
export function mount(element: HTMLElement, options: Partial<Options> = {}): Handle {
if (!element || typeof element.querySelectorAll !== 'function') return NOOP;
mounted.get(element)?.destroy();
let current = mergeOptions(DEFAULTS, options);
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const strokes = strokesOf(element)
.map((stroke) => ({ stroke, length: lengthOf(stroke) }))
.filter((entry): entry is { stroke: Element; length: number } => entry.length !== null);
let animations: Animation[] = [];
let observer: IntersectionObserver | null = null;
let started = false;
const start = (): void => {
if (started || reduced) return;
started = true;
const plan = planFor(
strokes.map((entry) => entry.length),
current,
);
strokes.forEach(({ stroke, length }, index) => {
const step = plan[index] as Step;
try {
const animation = stroke.animate(
{ strokeDasharray: [`${length}px ${length}px`, `${length}px ${length}px`], strokeDashoffset: [`${length}px`, '0px'] },
{ duration: step.duration, delay: step.delay, fill: 'backwards', easing: 'linear' },
);
animations.push(animation);
animation.finished.then(
() => {
animations = animations.filter((a) => a !== animation);
},
() => {},
);
} catch {
/* a stroke that cannot animate is simply there, finished */
}
});
};
const handle: Handle = {
update(next) {
current = mergeOptions(current, next);
},
destroy() {
observer?.disconnect();
observer = null;
for (const animation of animations) animation.cancel();
animations = [];
mounted.delete(element);
},
};
mounted.set(element, handle);
if (reduced || strokes.length === 0) return handle;
if (current.trigger === 0 && typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer?.disconnect();
observer = null;
start();
},
{ threshold: 0.2 },
);
observer.observe(element);
} else {
start();
}
return handle;
}