Bringing Skia to the Web

If you do not know what Skia is — read this article first.

The Problem

You want Skia's power. In the browser. Without plugins.

Historically, impossible. Browsers give you HTML Canvas—a simple 2D drawing API that's fine for basic graphics but lacks advanced features. No complex path operations. Limited text shaping. No hardware-accelerated effects.

Then WebAssembly arrived.

What CanvasKit Is

CanvasKit is Skia compiled to WebAssembly (WASM). The entire C++ graphics library, running in your browser at near-native speed.

It exposes Skia's API through JavaScript bindings. You get:
— Full path operations
— Advanced text layout (HarfBuzz, paragraph layout)
— Hardware-accelerated effects
— All blend modes
— Shader support (RuntimeEffect)
— Everything else Skia provides

Download size: ~2.9MB gzipped. That's the entire graphics engine.

Who Uses CanvasKit

Flutter Web: Switched exclusively to CanvasKit renderer. Every Flutter web app uses it.

Shopify: Built react-native-skia with CanvasKit for web support. React Native apps can now use Skia across mobile and web.

AntV (Ant Group): Created @antv/g-canvaskit renderer for their graphics library.

Figma (partially): Uses custom WebGL code primarily, but leverages Skia for specific algorithms.

43+ npm packages: Various frameworks and libraries building on CanvasKit.

The First Failed Attempt: The Fiddle

Official documentation points to https://jsfiddle.skia.org/canvaskit for an online playground.

Problem: It doesn't work. As of 2024, users get 403 Forbidden after Google login. The CanvasKit fiddle has been down intermittently.

The main Skia Fiddle (fiddle.skia.org) works for C++ code, but that's not helpful for web developers trying to learn CanvasKit.

Reality: No working online playground means learning from docs and examples alone. The docs exist, but...

The Documentation Problem

CanvasKit documentation exists at https://skia.org/docs/user/modules/canvaskit/

Here's what's actually there:

Here's what's missing:

The documentation assumes you know Skia's C++ API and can translate. If you don't, good luck.

What the Docs Don't Tell You: Memory Management

Critical rule: Objects created with new or Make* methods must be deleted manually.

const paint = new CanvasKit.Paint();
// ... use paint ...
paint.delete();  // MUST call this

JavaScript's garbage collector does not clean up WASM memory. Forget to delete, memory leaks forever (until page reload).

The mistake everyone makes: Creating paths/paints inside render loops without deleting them. 100 frames = 100 leaked objects.

We'll cover proper patterns in the interactive examples.

Bundler Integration Hell

Webpack Issues

Vite Issues

The Reality

Most CanvasKit projects include a webpack.config.js or vite.config.js with cryptic workarounds. Copy-paste and pray.

React Native Skia: Shopify's Approach

Shopify built react-native-skia to bring Skia to React Native. For web support, it uses CanvasKit.

Key insight: They wrapped CanvasKit in a declarative API. Instead of imperative draw calls, you describe what to draw:

<Canvas>
  <Rect x={0} y={0} width={256} height={256} color="blue" />
</Canvas>

The library handles:

This is not a CanvasKit fork. It's a high-level API that uses CanvasKit for web and native Skia for mobile.

Worth considering if you're building in React.

Who Actually Uses CanvasKit Directly

Flutter: No choice, it's the renderer.

Custom renderers: Building design tools, canvas apps, visualization libraries.

Performance-critical apps: Need GPU acceleration HTML Canvas can't provide.

Cross-platform tools: Want identical rendering on web and desktop (via Skia).

Most developers: Use a framework on top (Flutter, react-native-skia, AntV).

The Advantages

1. Real GPU Acceleration

CanvasKit uses WebGL/WebGPU. Complex paths render fast. Effects are hardware-accelerated. HTML Canvas can't compete.

2. Feature Parity

Need custom blend modes? Advanced text? Path boolean operations? CanvasKit has it. Canvas API doesn't.

3. Identical Rendering

Skia on desktop + CanvasKit on web = pixel-perfect match. Critical for design tools.

4. Control

Low-level API means no magic. You control clipping, layers, GPU resources. When you need performance, this matters.

The Disadvantages

1. Size

2.9MB gzipped is significant. Your bundle is instantly larger. First load is slower.

2. Learning Curve

Not HTML Canvas. Not SVG. It's Skia's API, translated to JavaScript. Expect weeks of learning.

3. Memory Management

Manual deletion or memory leaks. No escaping it.

4. Debugging

Errors in WASM are cryptic. Stack traces are useless. console.log() is your friend.

5. Bundler Hell

Every build tool needs custom configuration. Expect half a day fighting Webpack/Vite.

Interactive Examples

Few real CanvasKit demos exist online. So let's write our own — runnable, editable, hot-reloading right on the page.

Each example below mounts on its own <canvas>. The boilerplate (CanvasKitInit, MakeCanvasSurface, getCanvas) is hidden, the same way pixelart demos hide their getContext('2d'). You write the body. You get CK, surface, canvas, w, h, plus a loop(fn) helper for animation. Edit any block — the demo re-runs after 500ms. WASM objects you create get freed between re-runs automatically; in production you'd call .delete() yourself.

Example 1: Hello, Rectangle!

Static draw. Make a paint, fill it red, draw a rect, flush.

const paint = new CK.Paint(); paint.setColor(CK.Color(255, 0, 0, 1.0)); const rect = CK.LTRBRect(50, 50, 200, 170); canvas.drawRect(rect, paint); surface.flush(); paint.delete();

CK.Color and CK.LTRBRect return Float32Arrays — they're not WASM objects, no .delete() needed. Paint is a WASM object wrapper; in a production app you'd call paint.delete() after the draw to free it. Try changing the color values, or swap LTRBRect for XYWHRect(x, y, width, height).

Example 2: Moving circle

Same setup, but animated. loop(fn) runs fn once per frame and pauses when the canvas scrolls out of view — no wasted GPU when you scroll past.

const paint = new CK.Paint(); paint.setColor(CK.Color(128, 0, 0, 1.0)); const oval = CK.LTRBRect(50, 50, 200, 170); let x = 50, y = 50, r = 30, sx = 3, sy = 3; loop(() => { x += sx; y += sy; if (x + r > w || x - r < 0) sx *= -1; if (y + r > h || y - r < 0) sy *= -1; oval[0] = x - r; oval[1] = y - r; oval[2] = x + r; oval[3] = y + r; canvas.drawOval(oval, paint); surface.flush(); });

Two things stand out: — paint is created once, used every frame. If we recreated it inside loop, we'd leak a Paint per frame (60 per second). — canvas.drawOval reuses the same Float32Array — we mutate oval[0..3] in place. Allocating a new one per frame would burn GC.

Example 3: Boolean operations on paths

A star and a plane (the plane follows the cursor). CK.Path.MakeFromOp(a, b, op) combines them: union, intersect, difference, xor. The dropdown picks the operation. MakeFromOp returns a new Path you must .delete(); the source paths stay alive for the demo.

The plane comes from an SVG path. We center it on the origin and rotate 45° clockwise once at setup, then per frame just addPath + offset to put it under the cursor.

const op = controls.select( 'mode', ['shapes', 'union', 'intersect', 'difference', 'xor'], 'intersect' ); const star = new CK.Path(); makeStar(star, 200, 130, 80, 35, 5); const planeSvg = "M16.63,105.75c0.01-4.03,2.3-7.97,6.03-12.38L1.09,79.73c-1.36-0.59-1.33-1.42-0.54-2.4l4.57-3.9c0.83-0.51,1.71-0.73,2.66-0.47l26.62,4.5l22.18-24.02L4.8,18.41c-1.31-0.77-1.42-1.64-0.07-2.65l7.47-5.96l67.5,18.97L99.64,7.45c6.69-5.79,13.19-8.38,18.18-7.15c2.75,0.68,3.72,1.5,4.57,4.08c1.65,5.06-0.91,11.86-6.96,18.86L94.11,43.18l18.97,67.5l-5.96,7.47c-1.01,1.34-1.88,1.23-2.65-0.07L69.43,66.31L45.41,88.48l4.5,26.62c0.26,0.94,0.05,1.82-0.47,2.66l-3.9,4.57c-0.97,0.79-1.81,0.82-2.4-0.54l-13.64-21.57c-4.43,3.74-8.37,6.03-12.42,6.03C16.71,106.24,16.63,106.11,16.63,105.75L16.63,105.75z"; const planeBase = CK.Path.MakeFromSVGString(planeSvg); const pb = planeBase.computeTightBounds(); planeBase.offset(-(pb[0] + pb[2]) / 2, -(pb[1] + pb[3]) / 2); const k = 0.9, c45 = cos(PI / 4), s45 = sin(PI / 4); planeBase.transform([k * c45, -k * s45, 0, k * s45, k * c45, 0]); const plane = new CK.Path(); const ghost = new CK.Paint(); ghost.setStyle(CK.PaintStyle.Stroke); ghost.setColor(CK.Color(160, 160, 160, 0.6)); const fill = new CK.Paint(); fill.setColor(CK.Color(60, 160, 90, 0.3)); fill.setAntiAlias(true); const edge = new CK.Paint(); edge.setStyle(CK.PaintStyle.Stroke); edge.setStrokeWidth(2); edge.setColor(CK.Color(60, 160, 90, 1)); edge.setAntiAlias(true); const PATH_OPS = { union: CK.PathOp.Union, intersect: CK.PathOp.Intersect, difference: CK.PathOp.Difference, xor: CK.PathOp.XOR, }; let lastOp = 'intersect'; loop(() => { plane.reset(); plane.addPath(planeBase); if(op() !== lastOp){ lastOp = op(); mouse.x = 200; mouse.y = 130; } plane.offset(mouse.x || 200, mouse.y || 130); canvas.clear(CK.WHITE); canvas.drawPath(star, ghost); canvas.drawPath(plane, ghost); if (op() === 'shapes') { canvas.drawAnchors(star); canvas.drawAnchors(plane); } else { const out = CK.Path.MakeFromOp(plane, star, PATH_OPS[op()]); canvas.drawPath(out, fill); canvas.drawPath(out, edge); canvas.drawTangents(out); out.delete(); } surface.flush(); }); function makeStar(p, cx, cy, R, r, n) { for (let i = 0; i < n * 2; i++) { const a = -PI / 2 + i * PI / n; const rr = i % 2 === 0 ? R : r; if (i === 0) p.moveTo(cx + cos(a) * rr, cy + sin(a) * rr); else p.lineTo(cx + cos(a) * rr, cy + sin(a) * rr); } p.close(); }

The star and the plane are both straight-line paths. The boolean output gets cubic beziers wherever Skia rounds intersection corners — that's where the red tangent handles drawn by canvas.drawTangents appear.

Example 4: Stroke styles

A 100×100 rect with one rounded corner. The dropdown picks a stroke preset — width, join shape, dash pattern. paint.setPathEffect(CK.PathEffect.MakeDash([on, off, …], phase)) is what turns a solid stroke into dashes. Joins (Miter / Round / Bevel) only show where the path turns; caps don't matter on a closed path.

const STYLES = { thin: { width: 1 }, 'thick-miter': { width: 10 }, 'thick-round': { width: 10, join: CK.StrokeJoin.Round }, 'thick-bevel': { width: 10, join: CK.StrokeJoin.Bevel }, dashed: { width: 4, dash: CK.PathEffect.MakeDash([14, 8], 0) }, dotted: { width: 5, cap: CK.StrokeCap.Round, dash: CK.PathEffect.MakeDash([0.01, 12], 0) }, }; const style = controls.select('style', Object.keys(STYLES), 'thick-round'); const rect = new CK.Path(); makeOneRoundedRect(rect, 160, 78, 100, 100, 24); const stroke = new CK.Paint(); stroke.setStyle(CK.PaintStyle.Stroke); stroke.setAntiAlias(true); stroke.setColor(CK.Color(40, 90, 180, 1)); loop(() => { const s = STYLES[style()]; stroke.setStrokeWidth(s.width); stroke.setStrokeJoin(s.join || CK.StrokeJoin.Miter); stroke.setStrokeCap(s.cap || CK.StrokeCap.Butt); stroke.setPathEffect(s.dash || null); canvas.clear(CK.WHITE); canvas.drawPath(rect, stroke); surface.flush(); }); function makeOneRoundedRect(p, x, y, w, h, r) { const k = r * 0.5523; p.moveTo(x + r, y); p.lineTo(x + w, y); p.lineTo(x + w, y + h); p.lineTo(x, y + h); p.lineTo(x, y + r); p.cubicTo(x, y + r - k, x + r - k, y, x + r, y); p.close(); }

Each style is a plain object; the loop just looks up the current one and applies its fields. MakeDash is called once at setup so there's no per-frame allocation. dotted is the trick case — Skia rejects a zero-length dash, so we use 0.01 and let StrokeCap.Round extend the tiny segment into a circle of diameter = stroke width.

Example 5: Filters and blend modes

A chess-board background drawn from /img/chess.png (the same sprite the diamond-gradient article uses), with a 150×150 rounded-corner rect on top. The dropdown picks an effect — blend modes go on the rect's paint via setBlendMode, image filters via setImageFilter, color tints via setColorFilter. await loadImage(url) decodes a PNG once and caches it on window for every block on the page.

Error: Line 8: Unexpected identifier 'multiply' ); > const chess = await loadImage('/img/chess.png'); const rect = CK.RRectXY(CK.LTRBRect(135, 53, 285, 203), 12, 12);

Three things to notice. Blend modes need a destination. On a blank white canvas every blend collapses to a boring result, so we tile a chess pattern first. Source vs destination. red.setImageFilter(blur) blurs the rect's red color (blur-src). To blur what's underneath the rect (blur-dst) we clip to the RRect, push a saveLayer with the blur as a backdrop filter, and pop straight back — Skia pulls the lower layer through the filter as it composites. Tint and invert use blend modes, not color filters. Modulate with green keeps only the green channel of the destination; Difference with white inverts it (|255 − dst|).

Use CanvasKit when:
— You need features HTML Canvas lacks
— GPU performance is critical
— Cross-platform rendering consistency matters
— You're building a design/graphics tool
— 2.9MB download is acceptable

Don't use CanvasKit when:
— HTML Canvas is sufficient
— Bundle size is critical
— Simple 2D drawing is all you need
— You can't invest time learning it

The Reality Check

CanvasKit brings Skia's power to the web. But it's not polished. Documentation is sparse. Bundler integration is messy. Memory management is manual.

I spent months learning this, hit every edge case and wrote workarounds for everything.

This series documents what actually works. Not what the docs say. What survives production.

Resources