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:
- Installation instructions (accurate)
- TypeScript definitions (in the npm package)
- API overview (basic)
- A few code examples (incomplete)
Here's what's missing:
- Memory management best practices (critical!)
- Bundler integration guides (Webpack/Vite have issues)
- Performance considerations (GPU vs CPU paths)
- Complete API reference (you need TypeScript definitions)
- Real-world examples (complex shapes, animations)
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
- WASM support is experimental
- Need to copy
canvaskit.wasmto build directory manually - "Can't resolve 'fs'" errors require config changes
- No official guide for Webpack 5
Vite Issues
- Development mode renames
CanvasKitInittoCanvasKitInit2(completely wrong) - Importing WASM from node_modules fails
- Need custom import solutions
- Build vs dev mode behave differently
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:
- Memory management (automatic deletion)
- Render loops
- Platform differences (native Skia vs CanvasKit)
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
- CanvasKit docs: https://skia.org/docs/user/modules/canvaskit/
- npm package: https://npmjs.com/package/canvaskit-wasm
- TypeScript definitions: Included in package at
types/ - Shopify react-native-skia: https://shopify.github.io/react-native-skia/
- Flutter Web renderer docs: Flutter's CanvasKit usage