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.
Install
Quick start
import { 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 listeners
Buttons
Three 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).
Controls
| 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.
Text decoration
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.
Options
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.
Motion
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.
Font (optional)
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; }
Build your own shapes
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 opacity
Also exported: roughEllipse, roughArrow, roughCheckmark, scribbleFill,
and the seeded PRNG mulberry32 with randomSeed.
License
MIT.