Guide

Canvas

Canvas from nsh/canvas is a view you draw into with the HTML Canvas 2D API: charts, progress rings, gauges, signatures, custom icons, anything that is easier to draw than to build out of views. Your draw function records the drawing in one go; the platform replays it natively (Core Graphics on iOS, android.graphics.Canvas on Android) at the screen’s resolution, so drawings stay sharp at any pixel density.

guide-canvas.tsx Edit in Playground Open in new tab
import { type GestureResponderEvent, mount, signal, Text, View } from "nsh";
import { createNativeValue, Easing } from "nsh/animated";
import { Canvas, Path2D } from "nsh/canvas";

const TAU = Math.PI * 2;
const heart = new Path2D("M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z");

function Button(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} activeOpacity={0.6} style={{ paddingHorizontal: 14, paddingVertical: 9, borderRadius: 10, backgroundColor: "#0969da" }}>
      <Text style={{ color: "#ffffff", fontWeight: "600" }}>{props.label}</Text>
    </View>
  );
}

function App() {
  // The ring's sweep is a native value: animating it redraws without running `draw`.
  const progress = createNativeValue(0.35);
  const target = signal(0.35);
  const go = () => {
    const to = Math.round(Math.random() * 100) / 100;
    target.set(to);
    progress.timing(to, { duration: 800, easing: Easing.inOut(Easing.cubic) });
  };

  // The chart reads a signal: changing it runs `draw` again.
  const data = signal([12, 30, 22, 48, 36, 60, 52, 75]);

  // Finger drawing through the responder system.
  const strokes = signal<[number, number][][]>([]);
  const point = (e: GestureResponderEvent): [number, number] => [e.locationX, e.locationY];

  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 16, paddingTop: 24, gap: 14 }}>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <Canvas
          style={{ width: 110, height: 110 }}
          draw={(ctx, { width, height }) => {
            const cx = width / 2;
            const cy = height / 2;
            const r = width / 2 - 8;
            ctx.lineWidth = 10;
            ctx.lineCap = "round";
            ctx.strokeStyle = "#e5e7eb";
            ctx.beginPath();
            ctx.arc(cx, cy, r, 0, TAU);
            ctx.stroke();
            const g = ctx.createConicGradient(-Math.PI / 2, cx, cy);
            g.addColorStop(0, "#22d3ee");
            g.addColorStop(1, "#6366f1");
            ctx.strokeStyle = g;
            ctx.beginPath();
            ctx.arc(cx, cy, r, -Math.PI / 2, progress.mul(TAU).sub(Math.PI / 2));
            ctx.stroke();
            ctx.fillStyle = "#1f2328";
            ctx.font = "600 18px system-ui";
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.fillText(`${Math.round(target() * 100)}%`, cx, cy);
          }}
        />
        <View style={{ gap: 10 }}>
          <Button label="Animate the ring" onPress={go} />
          <Canvas
            style={{ width: 48, height: 48 }}
            draw={(ctx) => {
              ctx.scale(2, 2);
              ctx.shadowColor = "rgba(207,34,46,0.4)";
              ctx.shadowBlur = 4;
              ctx.fillStyle = "#cf222e";
              ctx.fill(heart);
            }}
          />
        </View>
      </View>

      <Canvas
        style={{ height: 150, borderRadius: 10, backgroundColor: "#ffffff" }}
        draw={(ctx, { width, height }) => {
          const values = data();
          const pad = 16;
          const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
          const y = (v: number) => height - pad - (v / 100) * (height - 2 * pad);
          const area = ctx.createLinearGradient(0, pad, 0, height - pad);
          area.addColorStop(0, "rgba(59,130,246,0.35)");
          area.addColorStop(1, "rgba(59,130,246,0)");
          ctx.beginPath();
          values.forEach((v, i) => (i === 0 ? ctx.moveTo(x(i), y(v)) : ctx.lineTo(x(i), y(v))));
          ctx.lineTo(x(values.length - 1), height - pad);
          ctx.lineTo(x(0), height - pad);
          ctx.closePath();
          ctx.fillStyle = area;
          ctx.fill();
          ctx.beginPath();
          values.forEach((v, i) => (i === 0 ? ctx.moveTo(x(i), y(v)) : ctx.lineTo(x(i), y(v))));
          ctx.strokeStyle = "#3b82f6";
          ctx.lineWidth = 2.5;
          ctx.lineJoin = "round";
          ctx.stroke();
        }}
      />
      <Button label="New data" onPress={() => data.set(data().map(() => Math.round(10 + Math.random() * 85)))} />

      <Canvas
        style={{ flex: 1, borderRadius: 10, backgroundColor: "#ffffff" }}
        onStartShouldSetResponder={() => true}
        onResponderGrant={(e) => {
          strokes.set([...strokes(), [point(e)]]);
          return true;
        }}
        onResponderMove={(e) => {
          const all = strokes();
          strokes.set([...all.slice(0, -1), [...all[all.length - 1], point(e)]]);
        }}
        draw={(ctx, { width }) => {
          ctx.fillStyle = "#8c959f";
          ctx.font = "13px system-ui";
          ctx.textAlign = "center";
          ctx.fillText("Draw here", width / 2, 20);
          ctx.strokeStyle = "#1f2328";
          ctx.lineWidth = 3;
          ctx.lineCap = "round";
          ctx.lineJoin = "round";
          for (const s of strokes()) {
            ctx.beginPath();
            s.forEach(([px, py], i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));
            ctx.stroke();
          }
        }}
      />
    </View>
  );
}

mount(App);
import { mount, View } from "nsh";
import { Canvas } from "nsh/canvas";

function App() {
  return (
    <View style={{ flex: 1, padding: 24, paddingTop: 60 }}>
      <Canvas
        style={{ height: 200, borderRadius: 12, backgroundColor: "#ffffff" }}
        draw={(ctx, { width, height }) => {
          ctx.fillStyle = "#0969da";
          ctx.beginPath();
          ctx.arc(width / 2, height / 2, 60, 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = "#1f2328";
          ctx.lineWidth = 4;
          ctx.strokeRect(20, 20, width - 40, height - 40);
        }}
      />
    </View>
  );
}

mount(App);

A Canvas takes every prop of a View (style, classes, touch handlers, accessibility, ref) plus draw. It needs a size from its style or layout; its content is the drawing. Coordinates are in points, with the origin at the top left.

#When draw runs

draw(ctx, { width, height }) runs:

  • once the canvas’s size is known (after its first layout);
  • again when the canvas is resized;
  • again when a signal it read changes.

Each run replaces the whole drawing. Reading signals inside draw is how a canvas follows your data:

const values = signal([12, 30, 22, 48, 36, 60]);

<Canvas
  style={{ height: 160 }}
  draw={(ctx, { width, height }) => {
    const v = values();
    const x = (i: number) => (i / (v.length - 1)) * width;
    const y = (n: number) => height - (n / 100) * height;
    ctx.beginPath();
    v.forEach((n, i) => (i === 0 ? ctx.moveTo(x(i), y(n)) : ctx.lineTo(x(i), y(n))));
    ctx.strokeStyle = "#3b82f6";
    ctx.lineWidth = 2.5;
    ctx.lineJoin = "round";
    ctx.stroke();
  }}
/>

Context calls only record: nothing is drawn while draw runs, so you cannot read pixels back. The recording is sent to the platform as one operation after draw returns.

#The 2D context

CanvasRenderingContext2D is a subset of the HTML Canvas 2D API. What is supported:

State

MemberDescription
save(), restore()Push and pop the drawing state (styles, transform, clip).
fillStyle, strokeStyleA CSS color, a gradient, or a native value interpolating colors.
lineWidth, lineCap, lineJoin, miterLimitStroke shape.
setLineDash(segments), getLineDash(), lineDashOffsetDashes.
globalAlphaOpacity of what is drawn next, 0..1.
globalCompositeOperationHow it combines: "source-over", "destination-out", "multiply", "screen", …
shadowColor, shadowBlur, shadowOffsetX, shadowOffsetYShadows.
fontCSS font shorthand: "600 14px Inter" (sizes in px, pt or rem).
textAlign, textBaselineText placement.

Transforms: translate(x, y), scale(x, y), rotate(angle) (radians, clockwise), transform(a, b, c, d, e, f), setTransform(a, b, c, d, e, f), resetTransform().

Paths: beginPath(), moveTo, lineTo, quadraticCurveTo, bezierCurveTo, arc, arcTo, ellipse, rect, roundRect(x, y, w, h, radii), closePath(); then fill(rule?), stroke() and clip(rule?), each also taking a Path2D. Fill rules are "nonzero" and "evenodd".

Rectangles: fillRect, strokeRect, clearRect.

Text: fillText(text, x, y, maxWidth?), strokeText(...), measureText(text) (measured synchronously by the platform’s text engine with the current font; only width).

Gradients: createLinearGradient, createRadialGradient, createConicGradient, each with addColorStop(offset, color).

Images: drawImage(image, dx, dy), drawImage(image, dx, dy, dw, dh), drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh).

ctx.canvas.width and ctx.canvas.height give the canvas’s size in points.

Not supported: getImageData / putImageData and other pixel access, filter, isPointInPath, SVG images in drawImage. An invalid color or font drops that one operation, as in HTML Canvas.

#Paths and Path2D

Path2D holds a path you build once and fill, stroke or clip many times. It takes SVG path data, parsed natively, which makes icons easy:

import { Canvas, Path2D } from "nsh/canvas";

const heart = new Path2D("M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z");

function Heart(props: { size: number; color: string }) {
  return (
    <Canvas
      style={{ width: props.size, height: props.size }}
      draw={(ctx) => {
        ctx.scale(props.size / 24, props.size / 24); // the path is drawn on a 24-point grid
        ctx.fillStyle = props.color;
        ctx.fill(heart);
      }}
    />
  );
}

A Path2D has the same path methods as the context (moveTo, arc, roundRect, …) and addPath(other). new Path2D(other) copies a path.

#Gradients

<Canvas
  style={{ height: 120 }}
  draw={(ctx, { width, height }) => {
    const linear = ctx.createLinearGradient(0, 0, width, 0);
    linear.addColorStop(0, "#22d3ee");
    linear.addColorStop(1, "#6366f1");
    ctx.fillStyle = linear;
    ctx.fillRect(0, 0, width, height / 2);

    const radial = ctx.createRadialGradient(width / 2, height * 0.75, 4, width / 2, height * 0.75, 40);
    radial.addColorStop(0, "#fde68a");
    radial.addColorStop(1, "#f97316");
    ctx.fillStyle = radial;
    ctx.beginPath();
    ctx.arc(width / 2, height * 0.75, 28, 0, Math.PI * 2);
    ctx.fill();
  }}
/>

createConicGradient(startAngle, x, y) sweeps colors around a point; its start angle is in radians, clockwise from the x axis. A conic gradient on a stroked arc makes a gradient progress ring.

#Text

ctx.font = "600 20px system-ui";
ctx.fillStyle = "#1f2328";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("72%", width / 2, height / 2);

ctx.font = "11px system-ui";
const label = "100";
ctx.fillText(label, 32 - 6 - ctx.measureText(label).width, 16);

Font families are the same as in styles: system families and the names of fonts your app imports (see Fonts).

#Images

drawImage takes a URL, an imported asset (import logo from "./logo.png") or { uri }. The image is loaded natively, and the canvas draws again once it has loaded, so you do not need to track loading yourself. Remote URLs are subject to the app’s network capabilities.

import { Canvas } from "nsh/canvas";
import logo from "./logo.png";

<Canvas
  style={{ width: 120, height: 120 }}
  draw={(ctx) => {
    ctx.save();
    ctx.beginPath();
    ctx.arc(60, 60, 56, 0, Math.PI * 2);
    ctx.clip(); // a round avatar
    ctx.drawImage(logo, 4, 4, 112, 112);
    ctx.restore();
  }}
/>

The nine-argument form takes the source rectangle in the image’s pixels.

#Animating with native values

Any number argument of a drawing call, and fillStyle / strokeStyle, can be a native value or a node derived from it. Such arguments are read while the platform draws, and when the value animates, the platform redraws on its own, every frame, without running draw or any other JS.

import { mount, signal, Text, View } from "nsh";
import { createNativeValue, Easing } from "nsh/animated";
import { Canvas } from "nsh/canvas";

function Ring() {
  const progress = createNativeValue(0.3);
  const label = signal(30);
  const go = (to: number) => {
    label.set(Math.round(to * 100));
    progress.timing(to, { duration: 900, easing: Easing.inOut(Easing.cubic) });
  };
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
      <Canvas
        style={{ width: 140, height: 140 }}
        draw={(ctx, { width, height }) => {
          const cx = width / 2, cy = height / 2, r = width / 2 - 10;
          ctx.lineWidth = 12;
          ctx.lineCap = "round";
          ctx.strokeStyle = "#e5e7eb";
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
          ctx.stroke();
          ctx.strokeStyle = "#6366f1";
          ctx.beginPath();
          // The end angle reads the native value on every frame.
          ctx.arc(cx, cy, r, -Math.PI / 2, progress.mul(Math.PI * 2).sub(Math.PI / 2));
          ctx.stroke();
          ctx.fillStyle = "#1f2328";
          ctx.font = "600 22px system-ui";
          ctx.textAlign = "center";
          ctx.textBaseline = "middle";
          ctx.fillText(`${label()}%`, cx, cy);
        }}
      />
      <View onPress={() => go(Math.random())}>
        <Text style={{ color: "#0969da" }}>Animate</Text>
      </View>
    </View>
  );
}

mount(Ring);

The arc’s end angle is progress.mul(2π).sub(π/2): a node, so the ring sweeps natively for 900 ms while draw ran only once (and once more for the label, which reads a signal). A clock’s second hand works the same way: ctx.rotate(seconds.mul(Math.PI / 30)) with seconds animated linearly.

Colors animate the same way: ctx.fillStyle = t.interpolate({ inputRange: [0, 1], outputRange: ["#54aeff", "#cf222e"] }).

#Touch on a canvas

A canvas is a view, so it takes presses and the responder system. Finger drawing records points in a signal and draws them:

const strokes = signal<[number, number][][]>([]);

<Canvas
  style={{ height: 240 }}
  onStartShouldSetResponder={() => true}
  onResponderGrant={(e) => {
    strokes.set([...strokes(), [[e.locationX, e.locationY]]]);
    return true; // a scroll view around must not take the touches
  }}
  onResponderMove={(e) => {
    const all = strokes();
    strokes.set([...all.slice(0, -1), [...all[all.length - 1], [e.locationX, e.locationY]]]);
  }}
  draw={(ctx) => {
    ctx.lineWidth = 3;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    for (const s of strokes()) {
      ctx.beginPath();
      s.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
      ctx.stroke();
    }
  }}
/>

#Performance

  • A drawing is recorded in Rust as JS calls the context, and travels to the platform as one operation; there is no per-call bridge crossing.
  • draw re-records everything. Keep the work in draw proportional to what is shown, and keep data that changes every frame in native values rather than signals.
  • Split a drawing that has a static part and a frequently changing part into two canvases stacked on top of each other, so only one redraws.
  • Inside a zooming scroll view, canvases are drawn again at the zoomed resolution when the zoom ends.