samizdatco/skia-canvas

A uniquely capable, unusually fast <canvas> for Node.js

JavaScript

2,606

1,882 commits

updated Oct 12, 2026

See the code

README

Skia Canvas – A uniquely capable, unusually fast ‹canvas› for Node.js
Getting Started   ·   Documentation   ·   Release Notes   ·   Discussion Forum

Skia Canvas is a ‘headless’ vector graphics renderer and on-screen windowing toolkit that lets you write code using the familiar Canvas API but without needing a browser to run it in. Instead, your graphics code runs anywhere Node.js does, making it possible to render image files from a web service or display interactive animations in a native OS window on your desktop. Behind the scenes, it’s all powered by Skia, the GPU-accelerated graphics engine used by Google Chrome.

A More Capable Canvas

In addition to being a faithful emulation of the canvas standard with minimal dependencies, Skia Canvas includes a raft of extensions, adding 2D capabilities that reach well beyond what the browser’s <canvas> can do.

In particular, Skia Canvas can:

Installing Skia Canvas

If you’re running on a supported platform, installation should be as simple as:

npm install skia-canvas

For detailed installation instructions and runtime configuration options, take a look at the Getting Started page.

Example Usage

Skia Canvas’s classes and extensions to the standard are extensively covered in the API Documentation. But to give you a sense of some of the things you can achieve with it, here are some real-world examples:

Generating image files

import {Canvas} from 'skia-canvas'

let canvas = new Canvas(400, 400),
    ctx = canvas.getContext("2d"),
    {width, height} = canvas;

// draw an empty box with a gradient at its edges
let sweep = ctx.createConicGradient(Math.PI * 1.2, width/2, height/2)
sweep.addColorStop(0, "red")
sweep.addColorStop(0.25, "orange")
sweep.addColorStop(0.5, "yellow")
sweep.addColorStop(0.75, "green")
sweep.addColorStop(1, "red")
ctx.strokeStyle = sweep
ctx.lineWidth = 100
ctx.strokeRect(100,100, 200,200)

// render to multiple destinations using a background thread...
await canvas.toFile("rainbox.png", {density:2}) // save a ‘retina’ image
let pngData = await canvas.png // use a shorthand for canvas.toBuffer("png")
let pngEmbed = `<img src="${await canvas.toURL("png")}">` // embed it in a string

// ...or save the file synchronously from the main thread
canvas.toFileSync("rainbox.pdf")

  rainbox.png 👁️

Multi-page sequences

import {Canvas, loadCanvas} from 'skia-canvas'

let canvas = new Canvas(400, 400),
    ctx = canvas.getContext("2d"), // leave first page blank
    {width, height} = canvas

for (const color of ['orange', 'yellow', 'green', 'skyblue', 'purple']){
  ctx = canvas.newPage() // add pages 2–6
  ctx.fillStyle = color
  ctx.fillRect(0,0, width, height)
  ctx.fillStyle = 'white'
  ctx.arc(width/2, height/2, 40, 0, 2 * Math.PI)
  ctx.fill()
}

await canvas.toFile("page-{2}.png")  // save to files named `page-01.png`, `page-02.png`, etc.
await canvas.toFile("all-pages.pdf") // save to a single multi-page PDF file

// the multi-page PDF can be read back in and even drawn upon
let multipage = await loadCanvas("all-pages.pdf")
for (let [i, pg] of multipage.pages.entries()){
  pg.font = 'italic 12px serif'
  pg.textAlign = 'center'
  pg.textBaseline = 'middle'
  pg.fillText(`p. ${i+1}`, multipage.width/2, multipage.height/2)
}
await multipage.toFile("all-pages-labeled.pdf")

  all-pages-labeled.pdf 👁️

Rendering to a window

import {Window} from 'skia-canvas'

let win = new Window(300, 300)
win.title = "Canvas Window"
win.on("draw", e => {
  let ctx = e.target.canvas.getContext("2d")
  ctx.lineWidth = 25 + 25 * Math.cos(e.frame / 10)
  ctx.beginPath()
  ctx.arc(150, 150, 50, 0, 2 * Math.PI)
  ctx.stroke()

  ctx.beginPath()
  ctx.arc(150, 150, 10, 0, 2 * Math.PI)
  ctx.stroke()
  ctx.fill()
})

  screenshot 👁️

Wide-gamut colors

import {Canvas} from 'skia-canvas'

let pad = 16, size = 64, width = 4*size + 3*pad,
    canvas = new Canvas(336, 240),
    ctx = canvas.getContext("2d", {colorSpace:"display-p3"})

// CSS Color 4 syntax is supported everywhere (and colors can exceed the sRGB gamut)
for (let [p3, srgb] of [
  ["color(display-p3 1 0 0)", "#ff0000"], ["lch(75% 100 150)", "#00dc51"],
  ["lch(85% 80 170)", "#00f8b6"], ["color(display-p3 0 1 1)", "#00ffff"]
]){
  ctx.fillStyle = p3 // wide gamut color
  ctx.fillRect(pad, pad, size, size/2)
  ctx.fillStyle = srgb  // nearest sRGB equivalent
  ctx.fillRect(pad, pad + size/2, size, size/2)
  ctx.translate(size + pad, 0)
}
ctx.translate(-width, pad + size)

// gradients can select the color space used for interpolation
for (let {space, from, to, hue} of [
  {space:"srgb",  from:"navy", to:"gold"}, // perceptual midpoint is off-center
  {space:"oklab", from:"navy", to:"gold"}, // Oklab stays perceptually uniform
  {space:"oklch", from:"red",  to:"red", hue:"longer"}, // full 360° from a single hue
]){
  let ramp = ctx.createLinearGradient(0, pad, width, pad)
  if (hue) ramp.hueInterpolationMethod = hue // only applies to angle-based spaces
  ramp.colorInterpolationMethod = space
  ramp.addColorStop(0, from)
  ramp.addColorStop(1, to)
  ctx.fillStyle = ramp
  ctx.fillRect(0, pad, width, size/2)
  ctx.translate(0, pad + size/2)
}

await canvas.toFile("test-pattern.png")

  test-pattern.png 👁️

Integrating with Sharp.js

import sharp from 'sharp'
import {Canvas, loadImage} from 'skia-canvas'

let canvas = new Canvas(400, 400),
    ctx = canvas.getContext("2d"),
    {width, height} = canvas,
    [x, y] = [width/2, height/2]

ctx.fillStyle = 'red'
ctx.fillRect(0, 0, x, y)
ctx.fillStyle = 'orange'
ctx.fillRect(x, y, x, y)

// Render the canvas to a Sharp object on a background thread then desaturate
await canvas.toSharp().modulate({saturation:.25}).jpeg().toFile("faded.jpg")

// Convert an ImageData to a Sharp object and save a grayscale version
let imgData = ctx.getImageData(0, 0, width, height, {matte:'white', density:2})
await imgData.toSharp().grayscale().png().toFile("black-and-white.png")

// Create an image using Sharp then draw it to the canvas as an Image object
let sharpImage = sharp({create:{ width:x, height:y, channels:4, background:"skyblue" }})
let canvasImage = await loadImage(sharpImage)
ctx.drawImage(canvasImage, x, 0)
await canvas.toFile('mosaic.png')

  sharp exports 👁️

Benchmarks

Methodology

For each drawing test, the Cairo-based canvas library’s time is used as a baseline measurement and the other libraries’ Relative Speed values are presented as ‘n times faster’ multiples (e.g., 2× means it ran in half the time). Per Run times are the mean runtime across all the library’s iterations for a given test. The file sizes listed in the Output column vary between libraries in part due to Skia Canvas’s PNG exporter automatically selecting which adaptive filters to use.

Skia Canvas was tested in two modes: ‘serial’ and ‘async’. When running serially, each rendering operation is awaited before continuing to the next test iteration. When running asynchronously, all the test iterations are begun simultaneously and are executed in parallel using the library’s multi-threading support.

See full results here…

Path2D drawing

LibraryPer RunRelative Speed (50 iterations)Output
canvaskit-wasm 472 ms 0.3×   499 KB
canvas 133 ms 1.0×   506 KB
@napi-rs/canvas  99 ms 1.3×   501 KB
skia-canvas (serial)  45 ms 3.0×   331 KB
skia-canvas (async)  20 ms 6.7×   331 KB

Scale/rotate images

LibraryPer RunRelative Speed (30 iterations)Output
canvaskit-wasm 783 ms 1.0×   2.1 MB
canvas 819 ms 1.0×   1.9 MB
@napi-rs/canvas 193 ms 4.2×   2.1 MB
skia-canvas (serial) 124 ms 6.6×   2.0 MB
skia-canvas (async)  24 ms33.5×   2.0 MB

Gradients

LibraryPer RunRelative Speed (250 iterations)Output
canvaskit-wasm —————  ———    not supported ————— 
canvas  57 ms 1.0×   133 KB
@napi-rs/canvas  25 ms 2.3×   130 KB
skia-canvas (serial)   7 ms 7.8×   130 KB
skia-canvas (async)   3 ms16.3×   130 KB

SVG to PDF

canvas & napi-rs convert the input SVG to a bitmap rather than exporting it as a vector

LibraryPer RunRelative Speed (200 iterations)Output
canvaskit-wasm —————  ———    not supported ————— 
canvas  27 ms 1.0×   142 KB
@napi-rs/canvas  28 ms 1.0×   272 KB
skia-canvas (serial)   5 ms 5.6×    52 KB
skia-canvas (async)   2 ms17.0×    52 KB

PDF to PNG

rendered in JavaScript using PDF.js:

LibraryPer RunRelative Speed (20 iterations)Output
canvaskit-wasm —————  ———    not supported ————— 
canvas 768 ms 1.0×   3.1 MB
@napi-rs/canvas 674 ms 1.1×   3.4 MB
skia-canvas (serial) 458 ms 1.7×   1.9 MB
skia-canvas (async) 253 ms 3.0×   1.9 MB

rendered natively in Rust using Hayro:

skia-canvas (serial) 154 ms 5.0×   1.3 MB
skia-canvas (async)  70 ms11.0×   1.3 MB

Text rendering

LibraryPer RunRelative Speed (200 iterations)Output
canvaskit-wasm  97 ms 0.2×   150 KB
canvas  24 ms 1.0×   161 KB
@napi-rs/canvas  19 ms 1.2×   155 KB
skia-canvas (serial)  14 ms 1.7×   127 KB
skia-canvas (async)   4 ms 5.7×   127 KB

Acknowledgements

This project is deeply indebted to the work of the Rust Skia project whose Skia bindings provide a safe and idiomatic interface to the mess of C++ that lies underneath. Many thanks to the developers of node-canvas for their terrific set of unit tests. In the absence of an Acid Test for canvas, these routines were invaluable.

Notable contributors

  • @mpaparno contributed support for SVG rendering, raw image-buffer handling, WEBP import/export and numerous bug fixes
  • @Salmondx developed the initial Raw image loading & rendering routines
  • @lucasmerlin helped get GPU rendering working on Vulkan
  • @cprecioso & @saantonandre corrected and expanded upon the TypeScript type definitions
  • @meihuanyu contributed filter & path rendering fixes

© 2020–2026 Samizdat Drafting Co.

canvas
node

samizdatco/skia-canvas

A uniquely capable, unusually fast <canvas> for Node.js

JavaScript

2,606

1,882 commits

updated Oct 12, 2026

See the code

README

Skia Canvas – A uniquely capable, unusually fast ‹canvas› for Node.js
Getting Started   ·   Documentation   ·   Release Notes   ·   Discussion Forum

Skia Canvas is a ‘headless’ vector graphics renderer and on-screen windowing toolkit that lets you write code using the familiar Canvas API but without needing a browser to run it in. Instead, your graphics code runs anywhere Node.js does, making it possible to render image files from a web service or display interactive animations in a native OS window on your desktop. Behind the scenes, it’s all powered by Skia, the GPU-accelerated graphics engine used by Google Chrome.

A More Capable Canvas

In addition to being a faithful emulation of the canvas standard with minimal dependencies, Skia Canvas includes a raft of extensions, adding 2D capabilities that reach well beyond what the browser’s <canvas> can do.

In particular, Skia Canvas can:

Installing Skia Canvas

If you’re running on a supported platform, installation should be as simple as:

npm install skia-canvas

For detailed installation instructions and runtime configuration options, take a look at the Getting Started page.

Example Usage

Skia Canvas’s classes and extensions to the standard are extensively covered in the API Documentation. But to give you a sense of some of the things you can achieve with it, here are some real-world examples:

Generating image files

import {Canvas} from 'skia-canvas'

let canvas = new Canvas(400, 400),
    ctx = canvas.getContext("2d"),
    {width, height} = canvas;

// draw an empty box with a gradient at its edges
let sweep = ctx.createConicGradient(Math.PI * 1.2, width/2, height/2)
sweep.addColorStop(0, "red")
sweep.addColorStop(0.25, "orange")
sweep.addColorStop(0.5, "yellow")
sweep.addColorStop(0.75, "green")
sweep.addColorStop(1, "red")
ctx.strokeStyle = sweep
ctx.lineWidth = 100
ctx.strokeRect(100,100, 200,200)

// render to multiple destinations using a background thread...
await canvas.toFile("rainbox.png", {density:2}) // save a ‘retina’ image
let pngData = await canvas.png // use a shorthand for canvas.toBuffer("png")
let pngEmbed = `<img src="${await canvas.toURL("png")}">` // embed it in a string

// ...or save the file synchronously from the main thread
canvas.toFileSync("rainbox.pdf")

  rainbox.png 👁️

Multi-page sequences

import {Canvas, loadCanvas} from 'skia-canvas'

let canvas = new Canvas(400, 400),
    ctx = canvas.getContext("2d"), // leave first page blank
    {width, height} = canvas

for (const color of ['orange', 'yellow', 'green', 'skyblue', 'purple']){
  ctx = canvas.newPage() // add pages 2–6
  ctx.fillStyle = color
  ctx.fillRect(0,0, width, height)
  ctx.fillStyle = 'white'
  ctx.arc(width/2, height/2, 40, 0, 2 * Math.PI)
  ctx.fill()
}

await canvas.toFile("page-{2}.png")  // save to files named `page-01.png`, `page-02.png`, etc.
await canvas.toFile("all-pages.pdf") // save to a single multi-page PDF file

// the multi-page PDF can be read back in and even drawn upon
let multipage = await loadCanvas("all-pages.pdf")
for (let [i, pg] of multipage.pages.entries()){
  pg.font = 'italic 12px serif'
  pg.textAlign = 'center'
  pg.textBaseline = 'middle'
  pg.fillText(`p. ${i+1}`, multipage.width/2, multipage.height/2)
}
await multipage.toFile("all-pages-labeled.pdf")

  all-pages-labeled.pdf 👁️

Rendering to a window

import {Window} from 'skia-canvas'

let win = new Window(300, 300)
win.title = "Canvas Window"
win.on("draw", e => {
  let ctx = e.target.canvas.getContext("2d")
  ctx.lineWidth = 25 + 25 * Math.cos(e.frame / 10)
  ctx.beginPath()
  ctx.arc(150, 150, 50, 0, 2 * Math.PI)
  ctx.stroke()

  ctx.beginPath()
  ctx.arc(150, 150, 10, 0, 2 * Math.PI)
  ctx.stroke()
  ctx.fill()
})

  screenshot 👁️

Wide-gamut colors

import {Canvas} from 'skia-canvas'

let pad = 16, size = 64, width = 4*size + 3*pad,
    canvas = new Canvas(336, 240),
    ctx = canvas.getContext("2d", {colorSpace:"display-p3"})

// CSS Color 4 syntax is supported everywhere (and colors can exceed the sRGB gamut)
for (let [p3, srgb] of [
  ["color(display-p3 1 0 0)", "#ff0000"], ["lch(75% 100 150)", "#00dc51"],
  ["lch(85% 80 170)", "#00f8b6"], ["color(display-p3 0 1 1)", "#00ffff"]
]){
  ctx.fillStyle = p3 // wide gamut color
  ctx.fillRect(pad, pad, size, size/2)
  ctx.fillStyle = srgb  // nearest sRGB equivalent
  ctx.fillRect(pad, pad + size/2, size, size/2)
  ctx.translate(size + pad, 0)
}
ctx.translate(-width, pad + size)

// gradients can select the color space used for interpolation
for (let {space, from, to, hue} of [
  {space:"srgb",  from:"navy", to:"gold"}, // perceptual midpoint is off-center
  {space:"oklab", from:"navy", to:"gold"}, // Oklab stays perceptually uniform
  {space:"oklch", from:"red",  to:"red", hue:"longer"}, // full 360° from a single hue
]){
  let ramp = ctx.createLinearGradient(0, pad, width, pad)
  if (hue) ramp.hueInterpolationMethod = hue // only applies to angle-based spaces
  ramp.colorInterpolationMethod = space
  ramp.addColorStop(0, from)
  ramp.addColorStop(1, to)
  ctx.fillStyle = ramp
  ctx.fillRect(0, pad, width, size/2)
  ctx.translate(0, pad + size/2)
}

await canvas.toFile("test-pattern.png")

  test-pattern.png 👁️

Integrating with Sharp.js

import sharp from 'sharp'
import {Canvas, loadImage} from 'skia-canvas'

let canvas = new Canvas(400, 400),
    ctx = canvas.getContext("2d"),
    {width, height} = canvas,
    [x, y] = [width/2, height/2]

ctx.fillStyle = 'red'
ctx.fillRect(0, 0, x, y)
ctx.fillStyle = 'orange'
ctx.fillRect(x, y, x, y)

// Render the canvas to a Sharp object on a background thread then desaturate
await canvas.toSharp().modulate({saturation:.25}).jpeg().toFile("faded.jpg")

// Convert an ImageData to a Sharp object and save a grayscale version
let imgData = ctx.getImageData(0, 0, width, height, {matte:'white', density:2})
await imgData.toSharp().grayscale().png().toFile("black-and-white.png")

// Create an image using Sharp then draw it to the canvas as an Image object
let sharpImage = sharp({create:{ width:x, height:y, channels:4, background:"skyblue" }})
let canvasImage = await loadImage(sharpImage)
ctx.drawImage(canvasImage, x, 0)
await canvas.toFile('mosaic.png')

  sharp exports 👁️

Benchmarks

Methodology

For each drawing test, the Cairo-based canvas library’s time is used as a baseline measurement and the other libraries’ Relative Speed values are presented as ‘n times faster’ multiples (e.g., 2× means it ran in half the time). Per Run times are the mean runtime across all the library’s iterations for a given test. The file sizes listed in the Output column vary between libraries in part due to Skia Canvas’s PNG exporter automatically selecting which adaptive filters to use.

Skia Canvas was tested in two modes: ‘serial’ and ‘async’. When running serially, each rendering operation is awaited before continuing to the next test iteration. When running asynchronously, all the test iterations are begun simultaneously and are executed in parallel using the library’s multi-threading support.

See full results here…

Path2D drawing

LibraryPer RunRelative Speed (50 iterations)Output
canvaskit-wasm 472 ms 0.3×   499 KB
canvas 133 ms 1.0×   506 KB
@napi-rs/canvas  99 ms 1.3×   501 KB
skia-canvas (serial)  45 ms 3.0×   331 KB
skia-canvas (async)  20 ms 6.7×   331 KB

Scale/rotate images

LibraryPer RunRelative Speed (30 iterations)Output
canvaskit-wasm 783 ms 1.0×   2.1 MB
canvas 819 ms 1.0×   1.9 MB
@napi-rs/canvas 193 ms 4.2×   2.1 MB
skia-canvas (serial) 124 ms 6.6×   2.0 MB
skia-canvas (async)  24 ms33.5×   2.0 MB

Gradients

LibraryPer RunRelative Speed (250 iterations)Output
canvaskit-wasm —————  ———    not supported ————— 
canvas  57 ms 1.0×   133 KB
@napi-rs/canvas  25 ms 2.3×   130 KB
skia-canvas (serial)   7 ms 7.8×   130 KB
skia-canvas (async)   3 ms16.3×   130 KB

SVG to PDF

canvas & napi-rs convert the input SVG to a bitmap rather than exporting it as a vector

LibraryPer RunRelative Speed (200 iterations)Output
canvaskit-wasm —————  ———    not supported ————— 
canvas  27 ms 1.0×   142 KB
@napi-rs/canvas  28 ms 1.0×   272 KB
skia-canvas (serial)   5 ms 5.6×    52 KB
skia-canvas (async)   2 ms17.0×    52 KB

PDF to PNG

rendered in JavaScript using PDF.js:

LibraryPer RunRelative Speed (20 iterations)Output
canvaskit-wasm —————  ———    not supported ————— 
canvas 768 ms 1.0×   3.1 MB
@napi-rs/canvas 674 ms 1.1×   3.4 MB
skia-canvas (serial) 458 ms 1.7×   1.9 MB
skia-canvas (async) 253 ms 3.0×   1.9 MB

rendered natively in Rust using Hayro:

skia-canvas (serial) 154 ms 5.0×   1.3 MB
skia-canvas (async)  70 ms11.0×   1.3 MB

Text rendering

LibraryPer RunRelative Speed (200 iterations)Output
canvaskit-wasm  97 ms 0.2×   150 KB
canvas  24 ms 1.0×   161 KB
@napi-rs/canvas  19 ms 1.2×   155 KB
skia-canvas (serial)  14 ms 1.7×   127 KB
skia-canvas (async)   4 ms 5.7×   127 KB

Acknowledgements

This project is deeply indebted to the work of the Rust Skia project whose Skia bindings provide a safe and idiomatic interface to the mess of C++ that lies underneath. Many thanks to the developers of node-canvas for their terrific set of unit tests. In the absence of an Acid Test for canvas, these routines were invaluable.

Notable contributors

  • @mpaparno contributed support for SVG rendering, raw image-buffer handling, WEBP import/export and numerous bug fixes
  • @Salmondx developed the initial Raw image loading & rendering routines
  • @lucasmerlin helped get GPU rendering working on Vulkan
  • @cprecioso & @saantonandre corrected and expanded upon the TypeScript type definitions
  • @meihuanyu contributed filter & path rendering fixes

© 2020–2026 Samizdat Drafting Co.

canvas
node

Project health

Overall

3.8/10

Maintained

10/10

Code review

0/10
OpenSSF Scorecard

Latest release

3.0.8 · Sep 25, 2025

Releases this year

0

Significant stargazers

(top 24 of 45)

Renée

499 followers · starred Dec 2024

fisker Cheung

557 followers · starred May 2022

Dominik Moritz

1,442 followers · starred Feb 2021

Gleb Bahmutov

4,949 followers · starred Dec 2024