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' 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.
<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.
<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.
<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.
<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.
<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.
toolscuts 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, thentoBlob()on save. - Showing a saved drawing.
readonlykeeps 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;
elementsis the state, and sending it is yours to do.
Do
- Use
bind:elementsand save what you get. It is JSON, andload()puts it back exactly, including the camera if you savedtoJSON(). - 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:thisfor the methods:undo,zoomToFit,insertImage,toSVG,toBlob,load,clear. - Give it a height.
heightis a property, not a class, soclasses.canvascan still restyle the stage. - Pick a tool from your own chrome with
bind:toolwhen the strip is off; the shortcuts keep working. TurnkeepToolon 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
toolbaroff withstylePanelon 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-96onclassfights the--board-hthe property sets. - Give a phone a
heighttaller 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 bindableDefaults to []
DrawingElement[] The document. Bindable, and the whole state of the drawing.
tool bindableDefaults to 'pen'
DrawingTool The tool in hand. Bindable, so a toolbar of your own can set it.
selection bindableDefaults to []
string[] Ids of the selected elements. Bindable.
camera bindableDefaults to { x: 0, y: 0, zoom: 1 }
Camera Where the camera is and how far in. Bindable.
ink bindableDefaults 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 topbottom
stylePanel Defaults to true
boolean panelSide Defaults to 'start'
DrawingBoardPanelSide startend
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 bindableDefaults 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