# Draw a Custom Dashboard Widget With Canvas2D

> When no built-in widget fits, a paint(ctx, w, h) script can draw anything: the Canvas2D drawing model, theme palette, and live dataset access explained.
>
> Widgets · June 30, 2026 · by Alex Spataru · https://serial-studio.com/blog/custom-widgets-canvas2d-painter

Dashboards are built from a vocabulary of standard widgets: plots, gauges, bars, compasses, LED panels. Serial Studio ships more than twenty of them, and most instruments you will ever need are in that set. But every so often the right visualization for your data simply does not exist as a prefab: a seven-segment readout, a polar plot, an attitude indicator drawn your way. The escape hatch is the Painter widget, a blank canvas that runs a drawing script you write, and if you have ever drawn on a `<canvas>` element in a browser, you already know how to use it.

## Immediate mode: redraw everything, every time

The Painter follows the Canvas2D model, which is immediate mode: nothing you draw persists. The widget calls your `paint` function at the refresh rate (60 Hz by default, configurable from 1 to 240), hands it a drawing context and the current width and height, and expects the function to redraw the entire widget from scratch:

```js
function paint(ctx, w, h) {
  ctx.fillStyle = theme.widget_base;
  ctx.fillRect(0, 0, w, h);

  if (datasets.length === 0) return;

  const ds = datasets[0];
  const frac = (ds.value - ds.min) / (ds.max - ds.min || 1);

  ctx.fillStyle = theme.accent;
  ctx.fillRect(16, h - 24, (w - 32) * frac, 8);

  ctx.font = "16px monospace";
  ctx.fillStyle = theme.widget_text;
  ctx.fillText(ds.value.toFixed(2) + " " + ds.units, 16, 24);
}
```

That is a complete widget: a horizontal bar with a numeric readout. The two guards are earning their keep, not padding the example: a freshly added widget has no datasets bound yet, so `datasets[0]` would be undefined on the first paint, and a dataset whose min equals its max would otherwise divide by zero. The `ctx` object exposes the familiar Canvas2D surface, `fillRect`, `beginPath`, `moveTo`, `lineTo`, `arc`, `stroke`, `fillText`, fill and stroke styles, fonts, and text alignment, mapped onto the application's native renderer. Redrawing everything at 60 Hz sounds wasteful and is not; simple vector drawing is far below what the renderer can do, and immediate mode is why the code stays this short. There is no scene graph to update, no invalidation logic, no state to keep consistent. The current data comes in, the picture goes out.

## Data in, state on the side

Inside the script, `datasets` is an array of the datasets bound to the widget, each carrying `value`, `min`, `max`, `title`, and `units`, so one script scales to however many channels you attach. Drawing wants the current value, but some visuals also need memory: a peak-hold marker, a decaying trail, a scrolling history. For that there is a second, optional hook, `onFrame()`, which runs when a new frame of data arrives (with timing available via `frame.timestampMs`). The clean division of labor is the one the bundled scripts use: `onFrame` updates state in top-level variables, `paint` only reads them and draws.

## Match the theme instead of fighting it

Hardcoded colors look right in one theme and wrong in the other. The script therefore gets a `theme` object with the active palette: `widget_base`, `alternate_base`, `widget_border`, `widget_text`, `widget_highlight`, `accent`, `alarm`, `placeholder_text`. Route every color through it, as the example above does, and your widget follows light mode, dark mode, and any custom theme without a line of per-theme code.

## Sharp edges worth knowing

Two practical notes save the most debugging time. First, `ctx.arc` maps to the native renderer's arc-path primitive, which means a partial arc should be preceded by a `moveTo` to the arc's start point; otherwise the path connects from wherever it was, and you get a chord you did not ask for. The bundled dial gauge script documents this trick in place. Second, the widget guards itself against runaway scripts: a `paint` call that has not returned after 250 milliseconds is terminated, and consistently slow paints (over 30 ms) earn a warning first. An infinite loop in your drawing code costs you one widget, not the dashboard, and the incoming data stream never stops behind it.

## Start from a template, not from zero

The fastest route is to open one of the eighteen bundled painter templates and bend it toward what you need. The set is a tour of what the API can do: an oscilloscope, a radar sweep with afterglow, an artificial horizon, a heatmap, a segmented VU meter with peak hold, a seven-segment display, a polar plot, an XY scope with a Lissajous mode, sparkline grids, status grids, progress rings. Each is a single readable file built on the same two hooks described here.

The Painter widget is part of [Serial Studio Pro](https://serial-studio.com/help/pro-vs-free). The [Painter reference](https://serial-studio.com/help/painter-widget) documents the full context API and the template gallery; if your custom widget needs calibrated values rather than raw ones, [dataset transforms](https://serial-studio.com/blog/sensor-data-transforms) happen upstream of the widget, so the values arriving in `datasets` are already the real units.
