sketch
@drietsch/sketch: an API-first JavaScript/TypeScript library for creating
and animating hand-drawn GUI mockups.
Build complete interfaces (windows, panels, forms, buttons, inputs, checkboxes, sliders, tabs, icons, text) in a sketch-style visual language, then script how someone uses them: the cursor moves, clicks, checks, chooses, drags and types on a seekable timeline. Output is SVG, as a string in Node or a live document in the browser, and everything is deterministic: the same document, seed and timestamp always produce byte-identical output.
Everything that can be drawn, edited, animated or interacted with has a public API representation. A future visual editor uses the same API.
Documentation: for agents (workflow, rules, recipes,
errors) and the API reference (every export, node type, prop,
step and error). Both ship in the package, with an llms.txt index.
import { createDemo } from '@drietsch/sketch';
const demo = createDemo({ width: 900, height: 600, seed: 42 });
demo.input({ id: 'email', x: 250, y: 200, width: 350, placeholder: 'Email' });
demo.button({ id: 'login', x: 470, y: 300, characters: 'Sign in' });
demo.timeline.moveCursor('email').click().type('email', 'hello@example.com').moveCursor('login').click();
demo.toSVG(1500); // the frame at 1.5 s, as an SVG string
demo.mount(document.querySelector('#stage')).play(); // or play it in the browser
Install
npm install @drietsch/sketch
ESM only, zero runtime dependencies, Node 24+ or any modern browser. Import it
from a bundler or a <script type="module">.
Concepts
Vocabulary. Nodes, properties and the document follow Figma's naming
wherever Figma has a name for the concept (FRAME, RECTANGLE, TEXT,
characters, fills, strokes, strokeWeight, cornerRadius,
textAlignHorizontal, nested children), so anyone, or any agent, who has
seen Figma's node model can read and write a sketch document without
learning a second one. What Figma does not have, the hand-drawn look and the
interaction timeline, lives under its own keys (sketch, timeline).
Scene. What exists: a flat store of nodes addressed by id, each with a
type, a position and optional parent. Children are positioned relative to
their parent's content area, so moving a window moves everything in it.
Nodes are created through the demo's factories and edited through the scene:
demo.scene.get(id) (or node(id), which throws instead of returning
undefined), update(id, patch), bounds(id), remove(id),
bringToFront(id) and hitTest(point) are the whole editing surface.
Components. Semantic nodes that expand into sketched parts: BUTTON,
INPUT, FRAME and WINDOW (plain or browser chrome), next to the
primitives RECTANGLE, ELLIPSE, LINE, VECTOR, TEXT and ICON. Inputs and buttons are simulated
graphical controls, not native HTML, so focus rings, carets, hover and
pressed looks are all drawn and all exportable.
Timeline. What happens and when. A fluent builder over a plain list of
steps: moveCursor, click, press, release, type, clear, wait,
focus, blur, setValue and set (patch a node from that moment on).
Durations are computed when the timeline is compiled against the scene:
cursor moves follow Fitts's law along seeded curved paths, clicks hold for a
human moment, typing has a human cadence. Pass duration to override.
Rendering. demo.frameAt(t) is a pure function of the document and the
time. toSVG(t) serialises it; mount(el) renders it into a live <svg>
and returns a Player with play, pause, seek, rate, loop and
events. Between two frames only the groups that changed are rebuilt.
Determinism. Every source of randomness, from the wobble of a line to the
curve of a cursor path to the pause between two keystrokes, is drawn from a
seeded stream keyed by the document seed and a stable name (the node id, the
step key). Changing one node never re-randomises another, and seeking to a
time gives the same frame whether you jump there or play through.
Math.random and the clock are never consulted while rendering.
API
Document
createDemo({ width, height, seed?, theme?, background?, font? }) |
A new demo. Omit seed for a random one and read it back from demo.seed. |
loadDemo(json, { font? }) |
Rebuilds a demo from toJSON() output; renders identically. |
demo.toJSON() |
A version 2 document: settings, theme, nested children back to front, custom icons, timeline. Version 1 documents load and migrate. |
demo.frameAt(t) / demo.toSVG(t) |
The frame at t ms as a virtual tree or an SVG string. |
demo.frames(fps) |
Every frame of the timeline, for export. |
demo.mount(el, { autoplay?, loop?, rate?, clock? }) |
Renders into el (a container or an <svg>) and returns a Player. |
demo.nodeAt(id, t) |
A node with the timeline's patches and live value applied at t. |
demo.duration |
Length of the timeline in ms. |
demo.registerIcon(name, def) |
An icon for this demo only; travels with toJSON(). |
Nodes
Every factory takes the node's props with an optional id (auto-generated as
rectangle-1, frame-2, … otherwise) and returns the stored node. Node
types are Figma's: RECTANGLE, ELLIPSE, LINE, VECTOR, TEXT, FRAME,
and sketch's own ICON, BUTTON, INPUT, WINDOW.
| Factory | Type | Props |
|---|---|---|
demo.rectangle |
RECTANGLE |
width, height, cornerRadius? |
demo.ellipse |
ELLIPSE |
width, height |
demo.line |
LINE |
x2, y2 |
demo.vector |
VECTOR |
d (SVG path data in local coordinates) |
demo.text |
TEXT |
characters, style? (a TypeStyle); \n breaks lines |
demo.icon |
ICON |
icon (a built-in name or an icon definition), size? |
demo.button |
BUTTON |
characters, icon?, width?, height?, variant? (primary), state? |
demo.input |
INPUT |
width, height?, value?, placeholder?, state? |
demo.frame |
FRAME |
width, height, title? (children start below the title bar) |
demo.window / demo.browser |
WINDOW |
width, height, title?, url? |
Controls
Base UI's catalogue of controls, as node types. Their model state follows
Base UI's names (checked, pressed, open, value) and is a prop on the
node, which the timeline can change live (see Semantic steps).
List-like controls take their items as props; their entries are drawn parts
with clickable regions, not nodes.
| Factory | Type | Props |
|---|---|---|
demo.checkbox |
CHECKBOX |
characters?, checked?, indeterminate? |
demo.checkboxGroup |
CHECKBOX_GROUP |
options, value?: string[], orientation? (vertical) |
demo.switch |
SWITCH |
characters?, checked? |
demo.toggle |
TOGGLE |
characters?, icon?, pressed? |
demo.toggleGroup |
TOGGLE_GROUP |
options, value?, multiple?, orientation? (horizontal) |
demo.radioGroup |
RADIO_GROUP |
options, value?, orientation? (vertical) |
demo.slider |
SLIDER |
width, value?, min? 0, max? 100, step? 1 |
demo.progress |
PROGRESS |
width, value?, max? 100, indeterminate?, characters? |
demo.meter |
METER |
width, value, min? 0, max? 100, characters? |
demo.separator |
SEPARATOR |
length, orientation? (horizontal) |
demo.avatar |
AVATAR |
characters? (initials), icon?, size? 36 |
demo.numberField |
NUMBER_FIELD |
width, value?, min?, max?, step? 1, placeholder? |
demo.otpField |
OTP_FIELD |
length? 6, value? |
demo.field |
FIELD |
label?, description?, error?; a vertical HUG container for one control |
demo.fieldset |
FIELDSET |
width, height, legend?; children start below the legend |
demo.form |
FORM |
a vertical container with itemSpacing: 10 by default |
demo.toolbar |
TOOLBAR |
orientation?; a padded row (or column) of its children |
demo.collapsible |
COLLAPSIBLE |
characters (header), open?; hides its children while closed |
demo.accordion |
ACCORDION |
width, items (labels or { label, characters? }), value?, multiple? |
demo.tabs |
TABS |
width, height, tabs, value?; shows the child at the active index |
demo.form({ id: 'signup', x: 30, y: 30, width: 280, layoutSizingVertical: 'HUG' });
demo.field({ id: 'f-plan', parent: 'signup', label: 'Plan', description: 'Change any time.', width: 280 });
demo.radioGroup({ id: 'plan', parent: 'f-plan', options: ['Free', 'Team'], value: 'Free' });
demo.field({ id: 'f-volume', parent: 'signup', label: 'Volume', width: 280 });
demo.slider({ id: 'volume', parent: 'f-volume', width: 280, value: 20 });
demo.tabs({ id: 'tabs', x: 340, y: 30, width: 250, height: 120, tabs: ['General', 'Billing'] });
demo.text({ id: 'general', parent: 'tabs', characters: 'General settings' }); // shown with the first tab
demo.button({ id: 'billing', parent: 'tabs', characters: 'Add card' }); // shown with the second
demo.nodeAt(id, t) returns a node with the live value, checked, open
(or pressed for a toggle) at time t.
Popups and overlays
Everything that opens over the page. A popup with an anchor sits against
that node (side: top, bottom, left, right) and needs no x/y;
dialogs, drawers and toasts sit against the page. Popups draw on an overlay
layer above every ordinary node, and their children (a dialog's buttons, a
popover's form) come with them. All of them keep open as model state.
| Factory | Type | Props |
|---|---|---|
demo.tooltip |
TOOLTIP |
anchor, characters, side? top, delay? 400; opens while hovered |
demo.previewCard |
PREVIEW_CARD |
anchor, title?, description?, width? 240; opens while hovered |
demo.popover |
POPOVER |
anchor, title?, description?, width? 260; a click toggles it; container |
demo.menu |
MENU |
characters?, icon?, items; a button that drops the list; value is the last choice |
demo.contextMenu |
CONTEXT_MENU |
anchor, items; a click on the anchor opens it over it |
demo.menubar |
MENUBAR |
menus: { label, items }[]; value is the open menu |
demo.navigationMenu |
NAVIGATION_MENU |
items: { label, items? }[]; links and dropdowns |
demo.select |
SELECT |
width, options, value?, placeholder? |
demo.combobox |
COMBOBOX |
width, options, value?, placeholder?; typing filters the list |
demo.autocomplete |
AUTOCOMPLETE |
as combobox, without the chevron; the list appears while typing |
demo.dialog |
DIALOG |
title, description?, width? 420; centred over a backdrop; container |
demo.alertDialog |
ALERT_DIALOG |
as dialog, but only its own buttons close it |
demo.drawer |
DRAWER |
title?, side? right, width? 320; a page-edge panel; container |
demo.toast |
TOAST |
title, description?, variant?, stack?; bottom-right, shown by default |
demo.scrollArea |
SCROLL_AREA |
width, height, contentHeight, value?; clips and scrolls its children |
Menu items are labels, { label, icon?, disabled?, items? } objects (items
opens a submenu on MENU and CONTEXT_MENU), or '-' for a separator.
demo.button({ id: 'share', x: 20, y: 20, characters: 'Share' });
demo.tooltip({ anchor: 'share', characters: 'Share this file' });
demo.popover({ id: 'share-pop', anchor: 'share', title: 'Share', width: 260 });
demo.input({ id: 'email', parent: 'share-pop', width: 236, placeholder: 'name@example.com' });
demo.button({ id: 'send', parent: 'share-pop', characters: 'Send', variant: 'primary' });
demo.dialog({ id: 'confirm', title: 'Delete this file?', description: 'It moves to the trash.' });
demo.button({ id: 'cancel', parent: 'confirm', characters: 'Cancel' });
demo.timeline.hover('share').open('share-pop').type('email', 'grace@example.com').click('send').close('share-pop');
demo.timeline.open('confirm').click('cancel').close('confirm');
Common props on every node: x, y, parent, visible, opacity,
interactive, sketchVariant, and the Figma-shaped visual properties:
fills: Paint[]andstrokes: Paint[], bottom to top; the first visible one is drawn. Only{ type: 'SOLID', color, opacity?, visible? }is supported.coloris a Figma{ r, g, b, a }in 0..1 or, for convenience, any CSS colour string. Absent means the component's default; an empty array means none.strokeWeight,strokeDashes,cornerRadius.sketch: { roughness?, bowing?, fillStyle?, hachureGap?, hachureAngle?, fillWeight? }, the hand-drawn look.fillStyleishachure(default),solid,zigzag,cross-hatch,dots,dashedorzigzag-line.
Text-bearing nodes (TEXT, BUTTON, INPUT, FRAME, WINDOW) take a
style: TypeStyle with fontSize, textAlignHorizontal (LEFT, CENTER,
RIGHT) and fills for the glyph colour. state accepts focused,
pressed, hovered and disabled.
demo.rectangle({
x: 20,
y: 20,
width: 120,
height: 60,
cornerRadius: 8,
fills: [{ type: 'SOLID', color: { r: 0.48, g: 0.64, b: 0.97 } }],
strokes: [{ type: 'SOLID', color: '#1f2430' }],
strokeWeight: 2,
strokeDashes: [6, 4],
sketch: { fillStyle: 'cross-hatch', roughness: 1.5 },
});
demo.text({
x: 20,
y: 100,
characters: 'Hello',
style: { fontSize: 18, fills: [{ type: 'SOLID', color: '#2f6fed' }] },
});
Relative placement
Instead of x and y, a node can say where it sits relative to one that
already exists. The position is resolved once, when the node is added, and
stored as plain coordinates; later edits do not reflow neighbours.
demo.frame({ id: 'card', x: 40, y: 40, width: 400, height: 300, title: 'Sign in' });
demo.text({ id: 'label', parent: 'card', x: 24, y: 20, characters: 'Email' });
demo.input({ id: 'email', below: 'label', gap: 10, width: 320 });
demo.button({ id: 'go', below: 'email', gap: 20, characters: 'Sign in' });
demo.button({ id: 'cancel', rightOf: 'go', gap: 12, characters: 'Cancel' });
- One of
below,above,rightOforleftOf, naming the reference node. gapis the distance between the two edges (default 0, may be negative).alignToaligns the cross axis:start(default),centerorend. Stacking vertically aligns left edges; stacking horizontally aligns tops.xoryon the cross axis overrides the aligned value; giving the placement's own axis is an error.- The new node inherits the reference's
parentunlessparentis given. - A
linekeeps its vector:x2/y2are read as offsets from the resolved origin.
Placement works on boxes, so a centred text or an auto-sized button lands where its visible edge should be. Placed and literal coordinates render byte-identically.
Auto-layout
A FRAME (or a WINDOW's content area) can lay out its children itself,
with Figma's auto-layout properties. Children of such a frame need no
coordinates; the frame can hug its content and children can fill the space.
demo.frame({
id: 'form',
x: 40,
y: 40,
width: 360,
layoutMode: 'VERTICAL',
itemSpacing: 12,
padding: 24,
layoutSizingVertical: 'HUG',
});
demo.text({ parent: 'form', characters: 'Email' });
demo.input({ parent: 'form', width: 100, layoutSizingHorizontal: 'FILL', placeholder: 'you@example.com' });
demo.frame({
parent: 'form',
height: 36,
layoutMode: 'HORIZONTAL',
layoutSizingHorizontal: 'FILL',
primaryAxisAlignItems: 'SPACE_BETWEEN',
fills: [],
strokes: [],
});
On the container: layoutMode (NONE, HORIZONTAL, VERTICAL),
itemSpacing, paddingLeft / paddingRight / paddingTop / paddingBottom
(or a padding shorthand: a number, [vertical, horizontal] or
[top, right, bottom, left]), primaryAxisAlignItems (MIN, CENTER,
MAX, SPACE_BETWEEN), counterAxisAlignItems (MIN, CENTER, MAX), and
layoutSizingHorizontal / layoutSizingVertical (FIXED, HUG, FILL). A
FRAME needs width/height only on a FIXED axis.
On any child: layoutSizingHorizontal / layoutSizingVertical set to FILL
to take the available space (rectangles, ellipses, inputs, buttons and
frames; text and icons are always their intrinsic size), and
layoutPositioning: 'ABSOLUTE' to opt out and keep its own x/y.
The stored node keeps what you wrote; scene.bounds(id) is the laid-out
truth, and toJSON() never contains computed sizes. Timeline set steps that
change spacing, a label or visibility reflow at that moment. Relative
placement (below: …) into an auto-layout frame is an error unless the node
is ABSOLUTE.
Timeline
demo.input({ id: 'email', x: 40, y: 40, width: 300 });
demo.text({ id: 'done', x: 40, y: 100, characters: 'Sent!', visible: false });
demo.timeline
.moveCursor('email') // or a point { x, y }; options { duration?, key? }
.click() // clicks where the cursor is; click('login') moves there first
.type('email', 'hello') // focuses the input if needed; '\b' deletes
.wait(300)
.set('done', { visible: true }) // patch a node from this moment on
.at(4000)
.blur(); // start the next step at an absolute time
demo.timeline.toJSON() and Timeline.fromJSON() round-trip the step list.
A step's key pins its random streams, so inserting steps before it never
changes its path or cadence.
Semantic steps
A control is used by what it does, not by where its parts are. Each of these
compiles into the cursor moves and clicks that a person would make, on the
exact region of the control (an option, a tab header, the increment button,
the slider thumb), and records the state change so stateAt(t) stays a pure
replay:
demo.timeline
.check('remember') // a CHECKBOX, SWITCH or TOGGLE; uncheck() and toggle() likewise
.check('toppings', 'Olives') // one option of a CHECKBOX_GROUP or multiple TOGGLE_GROUP
.choose('plan', 'Team') // a RADIO_GROUP, TOGGLE_GROUP, TABS or ACCORDION option
.choose('seats', 3) // a NUMBER_FIELD: clicks the stepper until it gets there
.drag('volume', 75) // a SLIDER: press the thumb, move, release; snaps to the step
.open('advanced') // a COLLAPSIBLE (or an ACCORDION item); close() likewise
.hover('save'); // moves there and rests
A plain click(id) applies the control's natural effect: a checkbox toggles,
a tab activates, a slider jumps to the cursor. A step a control cannot take
(check on a BUTTON) is a CompileError naming the step, the node and its
type; a step already satisfied (check on a checked box) only moves the
cursor there.
Popups follow the same rules through their anchors. hover(anchor) (or
hover(tooltip)) opens a tooltip or preview card after its delay and rests on
it; moving away closes it. open(popover) clicks its anchor, as does a plain
click(anchor), which toggles it. choose(select, option) opens the list if
needed and clicks the option; choose(menubar, item) opens the menu that
holds the item first, and choose(menu, item) walks into the submenu that
holds it. A click outside an open menu, select or popover dismisses it; a dialog
closes from its backdrop or its close mark (close(dialog) clicks that), an
alert dialog only from close(). A row scrolled out of a scroll area's
viewport cannot be a target until drag(area, offset) brings it into view.
Text and icons
Text is drawn from glyph data that ships with the package, so measurement,
carets and bounds are exact and identical in every environment and the SVG
needs no fonts. The default font is
Grape Nuts, a handwriting
face, set in capitals: every string is folded to upper case when it is drawn
and measured, so characters: 'Sign in' reads SIGN IN while the model keeps
'Sign in'. It covers printable ASCII, the Latin-1 letters and common
symbols, and the typographic characters UI copy uses (…, curly quotes,
dashes); anything else draws as a small box.
The previous default, the Hershey sans drawn as sketched single strokes, is
still there as HERSHEY_FONT (mixed case, ASCII only):
import { createDemo, HERSHEY_FONT } from '@drietsch/sketch';
const demo = createDemo({ width: 400, height: 200, font: HERSHEY_FONT });
A custom StrokeFont built from StrokeFontData (stroke polylines or
outline paths) can be passed the same way; a document saved with one must be
loaded with it.
Icons use the @sketchyicons/data
shape: 47 common icons are built in (iconNames() lists them), any of that
package's 1,756 icons can be passed to demo.icon({ icon }) directly, and
registerIcon(name, def) adds more by name. Set rough: true on a
definition of clean paths to sketch them.
Examples
The pages in examples/ load the built bundle. Run
pnpm build, then open them from a static server (or run
pnpm run verify:pages to render them all in headless Chromium).
sketch-styles.html: every fill style and primitivetext-and-icons.html: both fonts at several sizes, every built-in iconlogin-form.html: the briefing's mockup with every component statelogin-demo.html: the same, animated, with a scrub bar overtoSVG(t)player.html: mounted into a live SVG and driven by thePlayerauto-layout.html: a signup form laid out entirely by auto-layout, playing on a loopcontrols.html: every control in its states, then a form driven by the semantic stepsoverlays.html: menus, selects, a popover, a dialog, a drawer, toasts and a scroll area over one timeline
Development
pnpm install
pnpm run check # lint, format, catalogue, build, docs, typecheck, tests, dist tests
pnpm run verify:components # every registered type has a factory, an export, README and API rows and a fixture
pnpm run verify:docs # every code block in the docs runs against the built package
pnpm run verify:pages # render every examples/ page in headless Chromium
pnpm run verify:package # pack, install into a temp project, smoke-test
The engine's 946-case golden digest and the library's per-fixture SVG digests
pin the visual output; a change to either must be deliberate. A new component
is one file in src/components/, a registry entry, a node interface, a
factory on Demo, a README row and a fixture; verify:components says which
of those is missing.
Credits
The sketch-geometry engine in src/sketch/ is derived from
roughjs by Preet Shihn (MIT). Text
uses Grape Nuts by Robert Leuschke (SIL OFL) and the Hershey Fonts; icons
come from sketchyicons, derived from Lucide and Feather. See
THIRD-PARTY-NOTICES.md.
License
MIT