API

nsh/canvas

nsh/canvas draws with an HTML Canvas 2D–style context. Your draw function records the drawing; the platform replays it natively (Core Graphics on iOS, android.graphics.Canvas on Android) at the screen’s resolution. Numbers and colors in a drawing may be native values: when they animate, the platform redraws on its own, without running draw again.

mod-canvas.tsx Edit in Playground Open in new tab
import { 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.5-9.5-9A5.5 5.5 0 0 1 12 6a5.5 5.5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z");

function Chip(props: { label: string; onPress: () => void }) {
  return (
    <Text
      onPress={props.onPress}
      activeOpacity={0.6}
      style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, overflow: "hidden", backgroundColor: "#e0e7ff", color: "#4338ca", fontWeight: "600" }}
    >
      {props.label}
    </Text>
  );
}

function App() {
  const data = signal([12, 30, 22, 48, 36, 60, 52, 75, 64, 88]);
  const progress = createNativeValue(0.3);
  const target = signal(0.3);
  const go = () => {
    const to = Math.round(Math.random() * 100) / 100;
    target.set(to);
    progress.timing(to, { duration: 900, easing: Easing.inOut(Easing.cubic) });
  };

  return (
    <View style={{ flex: 1, backgroundColor: "#f8fafc", padding: 20, paddingTop: 24, gap: 16 }}>
      <Text style={{ fontSize: 22, fontWeight: "700" }}>Canvas</Text>
      <Canvas
        style={{ height: 170, borderRadius: 12, backgroundColor: "#fff" }}
        draw={(ctx, { width, height }) => {
          const values = data();
          const pad = 16;
          const w = width - pad * 2, h = height - pad * 2;
          const x = (i: number) => pad + (i / (values.length - 1)) * w;
          const y = (v: number) => pad + h - (v / 100) * h;
          const area = ctx.createLinearGradient(0, pad, 0, pad + h);
          area.addColorStop(0, "rgba(99, 102, 241, 0.35)");
          area.addColorStop(1, "rgba(99, 102, 241, 0)");
          ctx.beginPath();
          ctx.moveTo(x(0), pad + h);
          values.forEach((v, i) => ctx.lineTo(x(i), y(v)));
          ctx.lineTo(x(values.length - 1), pad + h);
          ctx.closePath();
          ctx.fillStyle = area;
          ctx.fill();
          ctx.beginPath();
          values.forEach((v, i) => (i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v))));
          ctx.strokeStyle = "#6366f1";
          ctx.lineWidth = 2.5;
          ctx.lineJoin = "round";
          ctx.stroke();
        }}
      />
      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <Canvas
          style={{ width: 110, height: 110 }}
          draw={(ctx, { width, height }) => {
            const cx = width / 2, cy = height / 2, r = width / 2 - 10;
            ctx.lineWidth = 12;
            ctx.lineCap = "round";
            ctx.strokeStyle = "#e2e8f0";
            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 = "#0f172a";
            ctx.font = "600 18px system-ui";
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.fillText(`${Math.round(target() * 100)}%`, cx, cy);
          }}
        />
        <Canvas
          style={{ width: 96, height: 96 }}
          draw={(ctx) => {
            ctx.save();
            ctx.scale(4, 4);
            ctx.fillStyle = "#ef4444";
            ctx.fill(heart);
            ctx.restore();
          }}
        />
      </View>
      <View style={{ flexDirection: "row", gap: 10 }}>
        <Chip label="New point" onPress={() => data.update((v) => [...v.slice(1), Math.round(Math.random() * 90) + 5])} />
        <Chip label="Animate ring" onPress={go} />
      </View>
    </View>
  );
}

mount(App);
import { Canvas, CanvasGradient, CanvasRenderingContext2D, Path2D } from "nsh/canvas";

See Canvas for the guide and <Canvas> for its props.

#Canvas

function Canvas(props: CanvasProps): YNode;

interface CanvasProps extends ViewProps {
  draw: (ctx: CanvasRenderingContext2D, size: CanvasSize) => void;
}

interface CanvasSize {
  width: number;   // points
  height: number;
}

A view drawn with a 2D context. It takes every view prop (style, classes, touch handlers, accessibility, ref), plus:

PropTypeDescription
draw(ctx, { width, height }) => voidDraws the canvas. Runs once the size is known, again when the signals it reads change, and again when the canvas resizes. Native values passed as numbers or colors animate without running it.

Give the canvas a size through its style (width / height, flex: 1, aspectRatio, …). Coordinates are in points, the origin at the top left.

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

function App() {
  const values = signal([12, 30, 22, 48, 36, 60, 52, 75]);
  return (
    <View style={{ flex: 1, padding: 24 }}>
      <Canvas
        style={{ height: 160 }}
        draw={(ctx, { width, height }) => {
          const v = values();
          const step = width / (v.length - 1);
          ctx.strokeStyle = "#6366f1";
          ctx.lineWidth = 3;
          ctx.lineJoin = "round";
          ctx.beginPath();
          v.forEach((n, i) => (i ? ctx.lineTo(i * step, height - n * 2) : ctx.moveTo(0, height - n * 2)));
          ctx.stroke();
        }}
      />
      <Text onPress={() => values.update((v) => [...v.slice(1), Math.random() * 80])}>Next</Text>
    </View>
  );
}

mount(App);

#Animating without redrawing

Any argument typed CanvasNumber and any paint (fillStyle, strokeStyle) accepts a NativeNode. The canvas then reads the node while it draws, and redraws natively on every frame of its animation:

import { createNativeValue, Easing } from "nsh/animated";
import { Canvas } from "nsh/canvas";

const progress = createNativeValue(0);

<Canvas
  style={{ width: 120, height: 120 }}
  draw={(ctx, { width }) => {
    const c = width / 2;
    ctx.lineWidth = 12;
    ctx.lineCap = "round";
    ctx.strokeStyle = "#6366f1";
    ctx.beginPath();
    // The end angle is a native node: animating `progress` redraws natively.
    ctx.arc(c, c, c - 10, -Math.PI / 2, progress.mul(Math.PI * 2).sub(Math.PI / 2));
    ctx.stroke();
  }}
/>;

progress.timing(0.75, { duration: 900, easing: Easing.inOut(Easing.cubic) });
type CanvasNumber = number | NativeNode;
type CanvasPaint = string | CanvasGradient | NativeNode;

#CanvasRenderingContext2D

The context draw receives. Calls only record operations; the platform draws them after draw returns.

#State and transforms

readonly canvas: { readonly width: number; readonly height: number };
save(): void;
restore(): void;
translate(x: CanvasNumber, y: CanvasNumber): void;
scale(x: CanvasNumber, y: CanvasNumber): void;
rotate(angle: CanvasNumber): void;
transform(a: CanvasNumber, b: CanvasNumber, c: CanvasNumber, d: CanvasNumber, e: CanvasNumber, f: CanvasNumber): void;
setTransform(a: CanvasNumber, b: CanvasNumber, c: CanvasNumber, d: CanvasNumber, e: CanvasNumber, f: CanvasNumber): void;
resetTransform(): void;
MemberDescription
canvasThe canvas’s size in points.
save() / restore()Pushes / pops the drawing state (transform, clip, styles).
translate(x, y)Moves the origin.
scale(x, y)Scales the axes.
rotate(angle)Rotates the axes; radians, clockwise.
transform(a, b, c, d, e, f)Multiplies the transform by the matrix [a c e; b d f].
setTransform(a, b, c, d, e, f)Replaces the transform.
resetTransform()Back to the identity.

#Properties

PropertyTypeDescription
fillStyleCanvasPaintColor, gradient or native color node used by fill, fillRect, fillText.
strokeStyleCanvasPaintThe same for strokes.
lineWidthCanvasNumberStroke width in points.
lineCap"butt" | "round" | "square"How strokes end.
lineJoin"miter" | "round" | "bevel"How stroke segments join.
miterLimitnumberMiter joins longer than this (in line widths) are beveled.
lineDashOffsetCanvasNumberWhere the dash pattern starts.
globalAlphaCanvasNumberOpacity of what is drawn next, 0..1.
globalCompositeOperationstringHow what is drawn next combines with the canvas ("source-over", "multiply", …).
shadowColorstringThe color of shadows (transparent: none).
shadowBlurnumberShadow blur in points.
shadowOffsetX, shadowOffsetYnumberShadow offset in points.
fontstringCSS font shorthand, e.g. "600 14px Inter" (sizes in px, pt or rem).
textAlign"start" | "end" | "left" | "right" | "center"Where text sits horizontally relative to its x.
textBaseline"alphabetic" | "top" | "hanging" | "middle" | "ideographic" | "bottom"Where text sits vertically relative to its y.

Dashes: setLineDash(segments: number[]) and getLineDash(): number[].

#Paths

The context has a current path, built with the same methods as Path2D:

beginPath(): void;
moveTo(x, y): void;
lineTo(x, y): void;
quadraticCurveTo(cpx, cpy, x, y): void;
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y): void;
arc(x, y, radius, startAngle, endAngle, counterclockwise?: boolean): void;
arcTo(x1, y1, x2, y2, radius): void;
ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise?: boolean): void;
rect(x, y, w, h): void;
roundRect(x, y, w, h, radii?: CanvasNumber | CanvasNumber[]): void;
closePath(): void;

All coordinates are CanvasNumbers. Angles are in radians, clockwise from the x axis. roundRect’s radii is one radius or 2–4 as in CSS border-radius (top left first).

#Drawing

fill(path?: Path2D | "nonzero" | "evenodd", rule?: "nonzero" | "evenodd"): void;
stroke(path?: Path2D): void;
clip(path?: Path2D | "nonzero" | "evenodd", rule?: "nonzero" | "evenodd"): void;
fillRect(x, y, w, h): void;
strokeRect(x, y, w, h): void;
clearRect(x, y, w, h): void;
MethodDescription
fill(path?, rule?)Fills the current path (or path) with fillStyle, by the nonzero (default) or even-odd rule.
stroke(path?)Strokes the current path (or path) with strokeStyle.
clip(path?, rule?)Restricts what is drawn next to the path.
fillRect, strokeRectA rectangle, without touching the current path.
clearRectMakes a rectangle transparent.

#Text

fillText(text: string, x: CanvasNumber, y: CanvasNumber, maxWidth?: number): void;
strokeText(text: string, x: CanvasNumber, y: CanvasNumber, maxWidth?: number): void;
measureText(text: string): TextMetrics; // { width: number }

measureText is synchronous: it measures with the platform’s text engine and the current font. Only width is reported.

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

#Gradients

createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient;
createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient;
createConicGradient(startAngle: number, x: number, y: number): CanvasGradient;

class CanvasGradient {
  addColorStop(offset: number, color: string): void; // offset 0..1, any CSS color
}
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, "rgba(99, 102, 241, 0.4)");
g.addColorStop(1, "rgba(99, 102, 241, 0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, width, height);

#Images

drawImage(image: string | ImageAsset | { uri: string }, ...args: CanvasNumber[]): void;

Draws an image given as a URL, an imported asset (import logo from "./logo.png") or { uri }. It is loaded natively; the canvas draws again once it has loaded. Three forms, as in HTML:

  • drawImage(image, dx, dy): at its natural size.
  • drawImage(image, dx, dy, dw, dh): scaled into a rectangle.
  • drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh): part of it (source in image pixels).

#Path2D

class Path2D {
  constructor(path?: Path2D | string);
  addPath(path: Path2D): void;
  // and every path method: moveTo, lineTo, quadraticCurveTo, bezierCurveTo,
  // arc, arcTo, ellipse, rect, roundRect, closePath
}

A reusable path to fill, stroke or clip. The constructor copies another Path2D or parses SVG path data natively:

const heart = new Path2D("M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 6a5.5 5.5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z");
ctx.save();
ctx.scale(4, 4);
ctx.fillStyle = "#ef4444";
ctx.fill(heart);
ctx.restore();

#Not supported

Pixel access (getImageData, putImageData), filter, isPointInPath and SVG images are not available. An invalid color or font drops that one operation, as in HTML Canvas.