Skip to content
omaris

Inputs

Drawing Board

An infinite canvas you can actually draw on: pen, shapes, arrows, text and sticky notes, with pan, zoom, selection, undo and export.

import { DrawingBoard } from 'omaris'
Learn

Examples

Basic

Pick a tool and draw. Scroll to pan, ⌘-scroll or pinch to zoom, space to drag the canvas, ⌘Z to undo. The board is infinite in every direction.

Stroke
Weight
Stroke style
Opacity
<script lang="ts">	import { DrawingBoard, type DrawingElement } from 'omaris';​	let elements = $state<DrawingElement[]>([]);</script>​<DrawingBoard bind:elements height={460} class="w-full" />

From data

A board is a document, not a picture: this diagram is built from an array and then edited by hand like anything else. bind:elements is the whole state of the drawing, so saving it is JSON.stringify.

<script lang="ts">	import {		DrawingBoard,		createDrawingElement,		defaultDrawingStyle,		measureDrawingText,		type DrawingElement,		type DrawingText	} from 'omaris';​	const stage = (x: number, y: number, label: string, color: string): DrawingElement[] => [		createDrawingElement(			'rect',			{ ...defaultDrawingStyle, color, background: color, fillStyle: 'soft' },			{ x, y, width: 170, height: 72 }		),		measureDrawingText({			...createDrawingElement(				'text',				{ ...defaultDrawingStyle, color, fontSize: 18 },				{					x: x + 22,					y: y + 26				}			),			text: label		} as DrawingText)	];​	const arrow = (x: number, y: number, dx: number, dy: number): DrawingElement =>		({			...createDrawingElement('arrow', defaultDrawingStyle, {				x,				y,				width: Math.abs(dx),				height: Math.abs(dy)			}),			points: [				{ x: 0, y: 0 },				{ x: dx, y: dy }			]		}) as DrawingElement;​	let elements = $state<DrawingElement[]>([		...stage(60, 60, 'Draft', 'var(--ink-blue)'),		arrow(240, 96, 80, 0),		...stage(330, 60, 'In review', 'var(--ink-orange)'),		arrow(415, 132, 0, 70),		...stage(330, 210, 'Shipped', 'var(--ink-green)')	]);</script>​<DrawingBoard bind:elements tool="select" grid="lines" height={460} class="w-full" />

Annotating

Marking something up: a picture on the canvas, four tools, snapping off, and the strip at the foot so it does not cover the thing being annotated. Drop an image on the board, or paste one, to add another.

Stroke
Weight
Stroke style
Opacity
<script lang="ts">	import {		DrawingBoard,		createDrawingElement,		defaultDrawingStyle,		type DrawingElement,		type DrawingImage	} from 'omaris';​	/** A drawn screenshot, so the demo needs no network. */	const SHOT = `data:image/svg+xml;utf8,${encodeURIComponent(`		<svg xmlns="http://www.w3.org/2000/svg" width="720" height="440">			<rect width="720" height="440" fill="#f4f5f7"/>			<rect width="720" height="56" fill="#ffffff"/>			<rect x="24" y="20" width="120" height="16" rx="8" fill="#c9ccd4"/>			<rect x="24" y="88" width="300" height="120" rx="12" fill="#ffffff"/>			<rect x="352" y="88" width="344" height="120" rx="12" fill="#ffffff"/>			<rect x="24" y="232" width="672" height="184" rx="12" fill="#ffffff"/>			<rect x="48" y="120" width="180" height="14" rx="7" fill="#dfe1e6"/>			<rect x="48" y="150" width="120" height="30" rx="8" fill="#4b7bec"/>			<rect x="376" y="120" width="240" height="14" rx="7" fill="#dfe1e6"/>			<rect x="376" y="150" width="200" height="14" rx="7" fill="#eceef1"/>		</svg>`)}`;​	let elements = $state<DrawingElement[]>([		{			...createDrawingElement(				'image',				{ ...defaultDrawingStyle, strokeWidth: 0 },				{ x: 0, y: 0, width: 720, height: 440 }			),			src: SHOT,			alt: 'A dashboard screenshot'		} as DrawingImage	]);</script>​<DrawingBoard	bind:elements	tool="arrow"	ink={{		...defaultDrawingStyle,		color: 'var(--ink-red)',		background: 'var(--ink-yellow)',		strokeWidth: 4	}}	tools={['select', 'hand', 'arrow', 'rect', 'highlighter', 'text', 'note', 'image']}	toolbarPosition="bottom"	grid="none"	camera={{ x: -40, y: -20, zoom: 0.7 }}	height={480}	class="w-full"/>

Exporting

bind:this is the rest of the component: undo, fit, load, toSVG, toBlob, toDataURL. The export replays the scene at whatever size you ask for, with the theme's own colours baked in — draw something and take a picture of it.

Stroke
Weight
Stroke style
Opacity
0 elements
<script lang="ts">	import { Button, DrawingBoard, type DrawingElement } from 'omaris';​	let board = $state<DrawingBoard | null>(null);	let elements = $state<DrawingElement[]>([]);	let png = $state<string | null>(null);​	async function shoot() {		png = (await board?.toDataURL({ scale: 2, padding: 16 })) ?? null;	}</script>​<div class="flex w-full flex-col gap-3">	<DrawingBoard bind:this={board} bind:elements height={380} class="w-full" />​	<div class="flex flex-wrap items-center gap-2">		<Button size="sm" onclick={shoot} disabled={!elements.length}>Export PNG</Button>		<Button size="sm" variant="tonal" onclick={() => board?.zoomToFit()}>Fit</Button>		<Button size="sm" variant="text" onclick={() => board?.clear()}>Clear</Button>		<span class="text-label-md text-muted-foreground">{elements.length} elements</span>	</div>​	{#if png}		<img			src={png}			alt="The board, exported"			class="max-w-sm rounded-shape-md border border-border"		/>	{/if}</div>

On a phone

A phone-width board. Below about 30rem the chrome folds into one bar that scrolls sideways — palette, undo, redo, the tools, zoom, clear — and the paint panel becomes a sheet the palette button opens, so the drawing keeps the board. Two fingers pan and zoom, and a double-tap opens text.

Stroke
Weight
Stroke style
Opacity
<script lang="ts">	import { DrawingBoard, type DrawingElement } from 'omaris';​	let elements = $state<DrawingElement[]>([]);</script>​<div class="w-full max-w-[22.5rem]">	<DrawingBoard bind:elements height={460} /></div>

Overridden

The overridden case: a narrow container, a replaced height, three tools, no panels of its own, and chrome of your own over the canvas through children. Every part is reachable through classes.

<script lang="ts">	import { DrawingBoard, type DrawingElement, type DrawingTool } from 'omaris';​	let elements = $state<DrawingElement[]>([]);	let tool = $state<DrawingTool>('pen');	let board = $state<DrawingBoard | null>(null);​	const picks: { tool: DrawingTool; label: string }[] = [		{ tool: 'pen', label: 'Draw' },		{ tool: 'eraser', label: 'Erase' },		{ tool: 'select', label: 'Move' }	];</script>​<div class="w-full max-w-sm">	<DrawingBoard		bind:this={board}		bind:elements		bind:tool		height="16rem"		grid="none"		toolbar={false}		stylePanel={false}		zoomControls={false}		historyControls={false}		ink={{			color: 'var(--ink-violet)',			background: 'transparent',			strokeWidth: 4,			strokeStyle: 'solid',			fillStyle: 'soft',			opacity: 1,			fontSize: 20,			fontFamily: 'sans'		}}		class="rounded-shape-2xl border-2 border-primary/25"		classes={{ canvas: 'bg-surface-container-low' }}	>		<div			class="pointer-events-auto absolute inset-x-3 bottom-3 flex items-center justify-between				gap-1 rounded-full bg-surface-container/95 p-1 shadow-2 backdrop-blur-sm"		>			{#each picks as pick (pick.tool)}				<button					type="button"					class="flex-1 cursor-pointer rounded-full px-3 py-1.5 text-label-md text-muted-foreground						transition-[background-color,color] duration-150 ease-standard hover:text-foreground						aria-pressed:bg-primary aria-pressed:text-primary-foreground motion-reduce:transition-none"					aria-pressed={tool === pick.tool}					onclick={() => (tool = pick.tool)}				>					{pick.label}				</button>			{/each}			<button				type="button"				class="cursor-pointer rounded-full px-3 py-1.5 text-label-md text-muted-foreground					transition-colors duration-150 ease-standard hover:text-destructive					motion-reduce:transition-none"				onclick={() => board?.clear()}			>				Clear			</button>		</div>	</DrawingBoard></div>

Ai

The optional AI. ai takes the same generate as AI Image, and a sparkle joins the strip: select part of the drawing (or nothing, for all of it), say what it should become, and the picture lands on the board over what it came from. Leave ai off and the board is exactly as before.

Stroke
Weight
Stroke style
Opacity
<script lang="ts">	import { DrawingBoard, mockGenerate } from 'omaris';</script>​<DrawingBoard	height={420}	ai={{ generate: mockGenerate({ delay: [1200, 2200] }) }}	class="w-full"/>

When to use it

Use it for

  • A whiteboard: freehand, shapes, arrows, text and sticky notes on an infinite canvas. Pan, zoom, selection, undo and export are built in.
  • Marking up a picture. Put an image element on the board or drop one on it, then annotate. tools cuts the strip down to the four you want.
  • A diagram built from data. Elements are plain objects, so a flow chart can be generated from a record and edited by hand.
  • A signature or sketch field: tools={['pen', 'eraser']} with the panels off, then toBlob() on save.
  • Showing a saved drawing. readonly keeps pan and zoom and removes the rest.
  • Sketch to picture. ai={{ generate }} — the same function AI Image takes — adds a sparkle to the strip that sends the selection or the drawing to the model and puts the result back on the board. Optional; without it nothing changes.

Not for

  • Rich text with a caret, headings and lists → Rich Text Editor.
  • Cards in columns that people drag between → Kanban.
  • Cropping, straightening or framing one photo → Image Cropper.
  • Looking at a picture with zoom, swipe and next → Image Viewer.
  • Charts. Data drawn from numbers is a chart.
  • Multiplayer. There is no transport; elements is the state, and sending it is yours to do.

Do

  • Use bind:elements and save what you get. It is JSON, and load() puts it back exactly, including the camera if you saved toJSON().
  • Use data URLs for images. An object URL dies with the page and cannot be rasterised, so toBlob() comes back with a hole.
  • Use bind:this for the methods: undo, zoomToFit, insertImage, toSVG, toBlob, load, clear.
  • Give it a height. height is a property, not a class, so classes.canvas can still restyle the stage.
  • Pick a tool from your own chrome with bind:tool when the strip is off; the shortcuts keep working. Turn keepTool on only where people draw the same shape many times, since a shape normally lands selected.
  • Paint with the ink tokens, var(--ink-blue), var(--ink-red), not hex, when you build elements yourself. They stay legible in both themes.
  • Put it on a phone. Below about 30rem the chrome folds into one scrolling bar and the paint panel becomes a sheet; two fingers pan and zoom, and a double-tap opens text. A narrow desktop column folds the same way.

Don't

  • Put a thousand elements on it and expect smooth scrolling. It draws SVG, which is right for a board people draw on and wrong for a generated map.
  • Turn toolbar off with stylePanel on unless you also give people a way to pick a tool. The panel paints what the tool is about to draw.
  • Style the root's height with a class. h-96 on class fights the --board-h the property sets.
  • Give a phone a height taller than the screen. The canvas takes the whole gesture, so the page cannot scroll past it. height="min(32rem, 60svh)" leaves a way out.
  • Expect toBlob() to include a cross-origin image. The canvas is tainted and the export throws, as it does for Image Cropper.

Quick reference

toolbarPosition
  • top (default)
  • bottom

Which end of the board the tool strip sits at.

panelSide
  • start (default)
  • end

Which side the paint panel sits on.

API

DrawingBoard

An infinite canvas you can actually draw on: pen, shapes, arrows, text and sticky notes, with pan, zoom, selection, undo and export.

The board is a document, not a picture. Everything on it is a plain object in elements — position, paint and content — so it can be bound, stored, diffed, generated from data, and handed back to another board exactly as it was. Nothing is baked into pixels until you ask for a PNG.

Every part of the chrome is optional and every part is replaceable: tools picks the strip, toolbar, stylePanel, zoomControls and historyControls turn the panels off, and children puts your own over the canvas. With all of them off it is a viewer — readonly makes that official, and the pan and zoom still work.

It works on a phone without being told. Below about 30rem of board the chrome folds into one bar that scrolls sideways — palette, undo, redo, the tools, zoom, clear — and the paint panel becomes a sheet that button opens, so the drawing keeps the board instead of three floating panels. Targets grow under a coarse pointer, two fingers pan as well as zoom, and a double-tap opens text the way a double-click does.

bind:this gives you the rest: undo, redo, addElement, insertImage, zoomToFit, toSVG, toBlob, toJSON and load.

import { DrawingBoard } from 'omaris'
<DrawingBoard bind:elements onchange={save} height={520} />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
canvas
The stage the drawing lives on. Everything else floats over it.
surface
The drawing itself, in scene coordinates.
overlay
Selection, handles and the marquee — screen coordinates, never exported.
panel
A floating panel: the tool strip, the style bar, the clusters.
toolbar
The tool strip. Centred by auto margins between both edges rather than from left-1/2, which hands an absolute box only half the board to fit in and wraps the strip long before it has to.
style
The paint panel — colours, weight, opacity, and what to do with a selection. It is capped to the board and scrolls inside itself, so a short board gets a short panel rather than one hanging out of it.
section
A labelled row inside the style panel.
caption
The caption over a row of swatches.
row
The row a set of choices sits in.
button
One button: a tool, a swatch holder, a step, an action.
swatch
A colour chip.
choice
A choice that is not a colour — stroke weight, dash, fill.
slider
The opacity slider.
zoom
The zoom cluster, bottom start.
history
Undo, redo and the rest, bottom end — above the zoom cluster on a board too narrow for both.
status
The zoom readout between the two steppers.
separator
A hairline between groups in a panel.
editor
The text box that opens over a text element while it is edited.

Props

elements bindable

Defaults to []

DrawingElement[]

The document. Bindable, and the whole state of the drawing.

tool bindable

Defaults to 'pen'

DrawingTool

The tool in hand. Bindable, so a toolbar of your own can set it.

selection bindable

Defaults to []

string[]

Ids of the selected elements. Bindable.

camera bindable

Defaults to { x: 0, y: 0, zoom: 1 }

Camera

Where the camera is and how far in. Bindable.

ink bindable

Defaults to { ...defaultDrawingStyle }

DrawingStyle

The paint new elements are made with — and what a selection is repainted to. Bindable.

tools

Defaults to drawingBoardTools.map((spec) => spec.tool)

DrawingTool[]

Which tools the strip offers, in order.

palette

Defaults to drawingInk

DrawingSwatch[]

The colours in the stroke row.

fills

Defaults to drawingFills

DrawingSwatch[]

The colours in the fill row.

toolbar

Defaults to true

boolean
toolbarPosition

Defaults to 'top'

DrawingBoardToolbarPosition
top
bottom
stylePanel

Defaults to true

boolean
panelSide

Defaults to 'start'

DrawingBoardPanelSide
start
end
zoomControls

Defaults to true

boolean
historyControls

Defaults to true

boolean
grid

Defaults to 'dots'

DrawingGrid

The backdrop: 'dots', 'lines', or 'none' for a blank page.

gridSize

Defaults to 20

number

Grid spacing in scene units — and the step everything snaps to.

snap bindable

Defaults to false

boolean

Snap new and moved elements to the grid. Bindable.

keepTool

Defaults to false

boolean

Keep the tool in hand after a shape is drawn, instead of dropping back to select. For drawing ten of something in a row.

readonly

Defaults to false

boolean

Look, don't touch. Pan and zoom still work.

minZoom

Defaults to 0.1

number

How far out and in the camera may go.

maxZoom

Defaults to 8

number
maxHistory

Defaults to 100

number

How many steps of undo to keep.

height

Defaults to '32rem'

number | string

Height of the board. A number is px, a string any CSS length.

onchange
(elements: DrawingElement[]) => void

Fires after any change to the document, with the new elements.

onselect
(selected: DrawingElement[]) => void

Fires when the selection changes, with the selected elements.

ontool
(tool: DrawingTool) => void

Fires when the tool changes — including from a keyboard shortcut.

oncamera
(camera: Camera) => void

Fires while panning and zooming.

ai
DrawingBoardAi

The optional AI: a sparkle in the strip that draws from the sketch.

toolbarExtra
Snippet

Extra buttons at the end of the tool strip.

panelExtra
Snippet

Extra controls at the foot of the style panel.

children
Snippet

Your own chrome, over the canvas.

class
string
classes
DrawingBoardClasses