Install
ES modules, no dependencies. The engine is 9 KB gzipped and each figure about 2 KB; a page that imports one figure carries one.
Quick start
import { Mimo } from '@piyush_26/mimo/react';
import { coffee } from '@piyush_26/mimo/figures';
export default function Contact() {
return <Mimo figure={coffee} className="w-40" />;
}The figure renders its still frame on the server, then plays in the browser. It fills its parent's width as a square.
React
<Mimo> renders a <div> and takes any <div> attribute.
| Prop | Type | Default | What it does |
|---|---|---|---|
figure | Figure | The figure to draw, imported from @piyush_26/mimo/figures or your own file. | |
trigger | 'hover' | 'loop' | 'once' | 'none' | the figure's own | When it plays. hover plays while the pointer is over it, loop while on screen, once the first time it scrolls into view. |
className | string | Classes for the wrapper; size it here. |
Play on card hover
A hover figure plays while the pointer is over it, or over the nearest ancestor marked data-mimo-hover, and finishes its loop when the pointer leaves, so it always lands on its rest pose. Mark a whole card, and the figure plays wherever the pointer is on it.
<a href="/contact" data-mimo-hover className="card">
<Mimo figure={paperPlane} className="w-24" />
<span>Get in touch</span>
</a>Plain JavaScript
import { mount } from '@piyush_26/mimo';
import { compass } from '@piyush_26/mimo/figures';
const el = document.getElementById('figure')!;
el.classList.add('mimo');
const player = mount(el, compass, { trigger: 'loop' });
player.destroy();mount(el, figure, options) returns a player:
| Member | What it does |
|---|---|
play(loop?) | Plays once, or loops when loop is true. |
stop() | Stops and returns to the still frame. |
show(frame) | Shows one frame of the loop, for scrubbing. |
count | How many frames the loop has, at 24 fps. |
destroy() | Removes the drawing and its listeners. |
Still frames
still(figure) returns the rest pose as SVG markup, styles included. Use it for server rendering, emails, or anywhere a figure should not move.
import { still } from '@piyush_26/mimo';
import { summit } from '@piyush_26/mimo/figures';
const svg = still(summit);Lottie
lottie(figure) returns the figure as Lottie JSON at 12 fps, for native apps, design tools and anything that plays Lottie. It is much heavier than the live engine, so prefer the engine on the web.
The export paints ink #141413, accent #E3DACC and paper #FAF9F5. To theme it with lottie-web's SVG renderer, map those fills to your colours:
.player svg [fill='rgb(20,20,19)'] {
fill: var(--ink);
}
.player svg [fill='rgb(227,218,204)'] {
fill: var(--accent);
}
.player svg [fill='rgb(250,249,245)'] {
fill: var(--paper);
}Theming
Figures read three CSS custom properties, so they follow your theme with no setup.
| Property | Default | Paints |
|---|---|---|
--mimo-ink | currentColor | every line |
--mimo-accent | #e3dacc | the accent fills |
--mimo-accent-2, --mimo-accent-3 | --mimo-accent | the extra tints of scattered bits, like confetti |
--mimo-paper | Canvas | the fills that hide what is behind a part |
Set --mimo-paper to the colour of the surface the figure sits on. On a card, that is the card's background.
.card {
background: #f2efe6;
--mimo-paper: #f2efe6;
}
.dark {
--mimo-accent: #3d3a34;
}Performance
Each frame is drawn live, in about 0.3 ms, and redrawn at most 60 times a second. A hover figure only works while it plays; a looping figure stops when it leaves the screen, and browsers pause it in background tabs. Ten figures looping at once cost about 3.6 ms a frame on a laptop.
Loop one figure at a time, a hero for example, and let the rest play on hover.
Accessibility
Each figure is an image named by its own one-line description. When the reader asks for reduced motion, figures show their still frame and never play.
Writing a figure
A figure is a file that ends with figure({ name, means, frames, trigger, draw }), where draw(t) paints the pose at loop progress t. The skill writes these for you; this is the toolkit it draws with.
Space: a 400 × 400 viewBox, x right, y DOWN, origin top left. Keep the
drawing inside 40..360 on both axes: the outer band is breathing room.
Angles are degrees, 0 = pointing right, positive = clockwise on screen.
A shape is an array of [x, y] points; closed shapes carry `.closed = true`.
The kernel's transforms keep that flag; a plain .map() over the points drops it,
so move shapes with move/turn/grow/pose, or rebuild them with poly().
Paint (only inside draw; later calls paint over earlier ones)
ink(shape, {w, fill}) a hand-inked line along the shape. w scales the line (default 1;
0.6 to 1.4 is the useful range). fill: 'accent', 'accent2', 'accent3'
or 'paper' fills a
closed shape under its line, in one call
fill(shape, n) the accent tint, no line: a closed shape. n 2 or 3 picks a second or
third tint, only for small scattered bits like confetti (rule 02)
paper(shape) the background colour, no line: hides what is behind (a front face)
solid(shape) a closed shape filled with ink: dots, pupils, blots
Shapes (all return points; nothing is drawn until you paint them)
line(points, r) straight segments through an array of [x, y] points, open; r rounds
every joint (default 0). Hand-drawn things have soft corners: use 4 to 12
poly(points, r) the same, closed: triangles, flags, polygons
spline(points, closed) a smooth curve through the points (Catmull-Rom)
bez(a, c1, c2, b) one cubic Bézier, open
ray(cx, cy, ang, r0, r1) a straight line pointing out from (cx, cy) at ang, from radius r0 to r1:
rays, sparks, spokes
arc(cx, cy, r, a0, a1) an arc from angle a0 to a1; a full turn comes back closed
circle(cx, cy, r) closed
ellipse(cx, cy, rx, ry, rot)
rect(x, y, w, h, r) a rectangle with corner radius r (cut to half the short side)
blob(cx, cy, r, {n, wob, seed}) an organic closed shape: n lobes (7), wob 0..0.4 (0.15)
leaf(x, y, len, ang, wid, curl) a closed leaf from its base at (x, y), pointing at ang;
curl bends the tip sideways, in viewBox units (+ = clockwise)
Moving shapes
move(s, dx, dy) translate
turn(s, deg, cx, cy) rotate about (cx, cy)
grow(s, k, cx, cy) scale about (cx, cy); k a number or [kx, ky]
pose(s, {x, y, rot, scale, cx, cy}) all at once: scale and rotate about (cx, cy), then
move by (x, y). Missing keys do nothing; (cx, cy) defaults to (200, 200)
part(s, u0, u1) the stretch of s between arc-length fractions u0..u1, open:
part(s, 0, u) draws a line on; returns [] when u1 <= u0
at(s, u) the point at arc-length fraction u
dir(s, u) the heading in degrees at fraction u
Time (t is the loop's progress, 0 to 1; draw(1) must equal draw(0))
keys(t, [[t0, v0], [t1, v1, ease?], ...], ease?)
the value at t between keyframes. v is a number or an array of
numbers (a point, a shape). A stop's third item eases the segment
that ends on it; the default is ease.inOut
span(t, a, b) 0 before a, 1 after b, linear between
bump(t, a, b) 0 outside a..b, rising to 1 at the middle and back: a there-and-back
ease.linear .in .out .inOut .back (overshoots by about 10% of the change, then settles:
leave room for it in the frame)
mix(a, b, u), lerp(a, b, u), clamp(v, a, b), rad(deg)
rand(seed) a seeded generator of [0, 1) numbers. Make it INSIDE draw, so every
drawing gets the same sequence; Math.random is not allowed
Declaring the figure (the last statement of the file)
figure({ name, means, frames, trigger, draw })
name lowercase, one word or hyphenated
means one sentence, at most 140 characters: what the figure shows
frames the loop's length, counted at 12 per second: an even number, 24 to 96 (2 to 8 s).
It plays at the display's refresh rate (exports sample it at 24 fps), so motion
can be as fast as it needs to be
trigger 'hover' (plays while the pointer is over it, then finishes its loop), 'loop' (while on screen),
'once' (the first time it scrolls into view) or 'none'
draw (t, k) => void; k is INK