Hand-drawn UI controls. Every mount generates a fresh pen sketch from seeded randomness, and the stroke boils like an animated doodle. Zero dependencies, ~7 KB of JS gzipped (React wrappers add under 1 KB) and a 3 KB stylesheet. An optional pen font is a separate 31 KB.
npm i drawablyimport { drawablyButton } from "drawably";
import "drawably/style.css";
drawablyButton(document.querySelector("#done"), { variant: "solid" });React:
import { DrawablyButton } from "drawably/react";
import "drawably/style.css";
<DrawablyButton variant="solid" onClick={submit}>Done</DrawablyButton>Each attach call returns a sketch handle:
const sketch = drawablyButton(el);
sketch.resketch(); // redraw with a new random seed
sketch.resketch(42); // redraw with a specific seed
sketch.destroy(); // remove the SVG and all listenersThree variants: outline (default), solid, scribble. Buttons also carry a
state machine for async work:
const button = drawablyButton(el);
button.setState("loading"); // dims the button, boils faster
button.setState("error"); // redraws in red
button.setState("success"); // redraws in green
button.setState("idle");In React, pass the state prop; the sketch stays put and only the state
changes:
<DrawablyButton state={saving ? "loading" : "idle"}>Save</DrawablyButton>Override the state colours with --drawably-error and --drawably-success.
For secondary or destructive actions, set tone: "neutral" (warm grey) or
tone: "danger" (red).
| Function | Element it expects |
|---|---|
drawablyButton(el, opts) |
a <button> |
drawablyCheckbox(el, opts) |
wrapper containing <input type="checkbox"> |
drawablyRadio(el, opts) |
wrapper containing <input type="radio"> |
drawablyToggle(el, opts) |
wrapper containing <input type="checkbox"> |
drawablyInput(el, opts) |
wrapper containing an <input> |
drawablyTextarea(el, opts) |
wrapper containing a <textarea> |
drawablySelect(el, opts) |
wrapper containing a <select> |
drawablyDivider(el, opts) |
an <hr> or div |
drawablyCard(el, opts) |
any block element |
drawablyBadge(el, opts) |
any inline element |
drawablyList(el, opts) |
a <ul> or <ol>; each <li> gets a sketched marker |
Badges take variant: "outline" | "scribble"; lists take
marker: "dash" | "check". Selects reserve the widest option's width so
picking never shifts layout; in Chromium the options list gets a sketched
frame and pen check (appearance: base-select), Safari and Firefox keep the
OS popup.
The real inputs stay in the DOM, so keyboard, forms, labels and screen readers
all work as usual. The sketch is an aria-hidden SVG layered underneath.
Every control has a React counterpart in drawably/react: DrawablyButton,
DrawablyCheckbox, DrawablyRadio, DrawablyToggle, DrawablyInput,
DrawablyTextarea, DrawablySelect, DrawablyDivider, DrawablyCard,
DrawablyBadge, DrawablyList.
Annotate copy the way you would with a pen. Each attaches to an inline element and leaves its layout alone; use them on a word or a short phrase.
| Function | Draws |
|---|---|
drawablyUnderline(el, opts) |
a rough line under the text, re-sketched on hover |
drawablyHighlight(el, opts) |
a marker wash behind the text |
drawablyCircle(el, opts) |
a hand-drawn ellipse looping around the text |
drawablyArrow(from, to, opts) |
an arrow from one element to another |
import { DrawablyUnderline, DrawablyHighlight, DrawablyCircle, DrawablyArrow } from "drawably/react";
<p>
<DrawablyUnderline>Hand-drawn</DrawablyUnderline> UI, a{" "}
<DrawablyHighlight>fresh sketch</DrawablyHighlight> on{" "}
<DrawablyCircle>every mount</DrawablyCircle>.
</p>
<DrawablyArrow from={noteRef} to={buttonRef} />A decoration that wraps onto several lines gets one drawing per line. The
arrow's SVG is appended to <body> in document coordinates and redraws on
resize. Anchors inside a scrolling container will drift as it scrolls.
All controls take the same base options:
| Option | Default | What it does |
|---|---|---|
seed |
random | Omit for a unique sketch per mount, pass a number for a reproducible one |
roughness |
1 |
Wobble of the base sketch |
boil |
0.3 |
Px of frame-to-frame flicker; 0 renders one static path |
stroke, fill, paper |
pen blue / white | Colours, set as --drawably-* custom properties |
width |
2 |
Stroke width in px |
The colours are plain CSS custom properties, so a theme can set them once:
:root {
--drawably-stroke: #1a1a1a;
--drawably-fill: #1a1a1a;
}Type is Inter when the page has it loaded, falling back to system-ui. The
library loads no font unless you opt into the one below.
Strokes boil gently: three frames of the same sketch, micro-wobbled around a
shared base, cycled by pure CSS at 1200ms. Hover or press re-sketches
buttons, checkboxes, radios, toggles, underlines and circles; buttons also lift
on hover and sink on press. prefers-reduced-motion freezes everything to a
single static sketch, including the demo images above.
Drawably Pen is the same strokes as a typeface: a–z, A–Z, digits and
punctuation, built by the library's own pen code (font/) into a 31 KB
TrueType. It is not loaded by style.css; nothing in the library needs it.
If you want labels in the same hand as the chrome:
import "drawably/font.css";.drawably-button {
font-family: "Drawably Pen", Inter, sans-serif;
}The rough renderer is exported. Each function returns an SVG path string, and
variants produces the boil frames:
import { roughRoundedRect, roughLine, roughCircle, variants } from "drawably";
const frames = variants(
(o) => roughRoundedRect(0, 0, 200, 100, 12, o),
{ seed: 7, roughness: 1, boil: 0.3 },
);
// three path strings — render them and cycle opacityAlso exported: roughEllipse, roughArrow, roughCheckmark, scribbleFill,
and the seeded PRNG mulberry32 with randomSeed.
MIT.