Drawing Board
An infinite canvas you can actually draw on: pen, shapes, arrows, text and sticky notes, with pan, zoom, selection, undo and export.
Walkthrough
1. 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" /> 2. 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" /> 3. 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"/> 4. 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> 5. 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> 6. 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> 7. 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"/>