Skip to content

GPU-accelerated 2D vector graphics with Skia WASM — shapes, text, gradients, and path operations.

What you'll learn

  • How to initialize Skia, mount a SkiaCanvas in texture or overlay mode, and render shapes
  • How to load fonts and apply custom paints (gradients, blend modes, blur, drop shadow)
  • How to perform programmatic path operations — union, intersect, difference, xor

@three-flatland/skia brings Skia's GPU-accelerated vector rendering to Three.js via WASM, built with Zig. It auto-detects WebGPU or WebGL from your renderer and supports both Three.js and React Three Fiber.

You probably don't need this page yet. Skia earns its WASM weight when you need real vector graphics in 3D — UI overlays, dynamic text, path operations, gradients. For static images and animated sprites, plain Sprite2D is the right answer.

Includes a native WebGPU backend (Graphite/Dawn) — CanvasKit's npm package ships WebGL only. At 857 KB (WebGPU) / 1 MB (WebGL) brotli vs CanvasKit's 2.2 MB.

Terminal window
npm install @three-flatland/skia

Peer dependencies: three >= 0.183.1. For React: @react-three/fiber >= 10.0.0-alpha.2 and react >= 19.0.0.

Skia ships ~1 MB WASM binaries (one for WebGL, one for WebGPU). Vite handles this automatically in dev — no config needed. For production builds or other bundlers, copy the WASM to your public directory:

Terminal window
npx skia-wasm public/skia

This copies skia-gl.wasm and skia-wgpu.wasm to public/skia/. Then tell Skia where to find them:

// Option 1: pass the URL directly
const skia = await Skia.init(renderer, { wasmUrl: '/skia/skia-gl.wasm' })
// Option 2: set env vars (replaced at build time by your bundler)
// webpack.config.js
const { DefinePlugin } = require('webpack')
module.exports = {
plugins: [
new DefinePlugin({
'process.env.SKIA_WASM_URL_GL': JSON.stringify('/skia/skia-gl.wasm'),
'process.env.SKIA_WASM_URL_WGPU': JSON.stringify('/skia/skia-wgpu.wasm'),
}),
],
}
import { Skia } from '@three-flatland/skia'
import { SkiaCanvas, SkiaRect } from '@three-flatland/skia/three'
// Auto-detects WebGL or WebGPU from the renderer
const skia = await Skia.init(renderer)
const canvas = new SkiaCanvas({ renderer, width: 512, height: 512 })
canvas.add(new SkiaRect())
// In your animation loop:
canvas.invalidate()
canvas.render()

The root node that owns the render target and walks children to draw. Two modes:

SkiaCanvas render modes — where the draw lands relative to the scene renderone animation frame, time flows left to righttime →renderer.render(scene, camera)the Three.js scene drawTexture mode{ before: 'render' }skiaCanvas.render()draws shapes firstrender targetcanvas.texturerenders tobound as mesh.material.mapOverlay mode{ after: 'render' }skiaCanvas.render()draws over the scenescreen framebufferscene + Skia on topscene draws firstcomposites
SkiaCanvas render modes — texture mode draws before the scene and feeds a mesh map; overlay mode composites onto the framebuffer after the scene.

| Mode | Description | Use Case | |------|-------------|----------| | Texture | Renders to an off-screen texture (canvas.texture) | Apply Skia output to 3D mesh materials | | Overlay | Renders to the screen framebuffer | HUD, debug text, UI on top of 3D |

// Texture mode (default)
const shapes = new SkiaCanvas({ renderer, width: 1024, height: 880 })
// Overlay mode
const overlay = new SkiaCanvas({
renderer,
width: window.innerWidth * dpr,
height: window.innerHeight * dpr,
overlay: true,
})

SkiaCanvas does not auto-render. Call render() each frame — it automatically marks the canvas dirty and draws:

function animate() {
// 1. Render Skia shapes to texture
shapesCanvas.render()
// 2. Apply texture to mesh
material.map = shapesCanvas.texture
// 3. Render 3D scene
renderer.render(scene, camera)
// 4. Overlay on top
overlay.render()
}

All nodes extend SkiaNode (which extends Three.js Object3D) and accept inline paint props.

| Prop | Type | Description | |------|------|-------------| | fill | [r, g, b, a] | Fill color (0-1 floats) | | stroke | [r, g, b, a] | Stroke color | | strokeWidth | number | Stroke width in pixels | | opacity | number | 0-1 opacity | | blur | number | Gaussian blur sigma | | blendMode | BlendMode | Compositing blend mode | | paint | SkiaPaint | Explicit paint (overrides all above) |

{/* Rectangle with rounded corners */}
<skiaRect x={10} y={10} width={200} height={100}
cornerRadius={12} fill={[0.2, 0.4, 1, 1]} />
{/* Circle */}
<skiaCircle cx={150} cy={150} r={60} fill={[1, 0.3, 0.3, 1]} />
{/* Ellipse */}
<skiaOval x={100} y={100} width={200} height={120}
stroke={[1, 0.8, 0.2, 1]} strokeWidth={3} />
{/* Line */}
<skiaLine x1={10} y1={10} x2={200} y2={100}
stroke={[1, 1, 1, 0.5]} strokeWidth={2} />

Accepts SVG path data or an explicit SkiaPath reference:

{/* From SVG data string */}
<skiaPathNode d="M10 80 Q95 10 180 80" fill={[1, 1, 1, 1]} />
{/* From SkiaPath reference (for dynamic paths, boolean ops) */}
<skiaPathNode path={myPath} fill={[0.9, 0.3, 0.3, 0.9]} />

Requires a SkiaFont loaded via SkiaFontLoader:

<skiaTextNode text="Hello Skia" font={font}
fill={[1, 1, 1, 1]} x={20} y={40} />

Groups apply 2D transforms, clipping, and layer effects to all children:

<skiaGroup position={[100, 50, 0]} scale={[2, 2, 1]}>
<skiaRect fill={[1, 0, 0, 1]} width={50} height={50} />
</skiaGroup>

Uses standard Object3D position, scale, and rotation.z (radians). The degrees prop is a convenience shorthand.

| Prop | Description | |------|-------------| | position | Translation (Object3D — [x, y, z]) | | rotation | Rotation (Object3D — rotation.z in radians) | | degrees | Rotation in degrees (convenience, overrides rotation.z) | | scale | Scale (Object3D — [x, y, z]) | | skewX, skewY | Skew (Skia-specific, no Object3D equivalent) | | opacity | Layer opacity | | blur | Gaussian blur on group | | shadow | Drop shadow ({ dx, dy, blur, color }) | | clipRect | [x, y, w, h] clip rectangle | | backdropBlur | Frosted glass effect |

SkiaFontLoader loads a SkiaTypeface — the font data independent of size. Call .atSize(n) to get a sized SkiaFont.

import { SkiaFontLoader } from '@three-flatland/skia/three'
const typeface = await SkiaFontLoader.load('/fonts/Inter.ttf')
const titleFont = typeface.atSize(32)
const bodyFont = typeface.atSize(14)
textNode.font = titleFont

The typeface resolves the SkiaContext lazily — if no context is available when atSize() is called, it falls back to the global SkiaContext.instance singleton. Inside a <SkiaCanvas> subtree, this is always available.

For gradients, shaders, and effects beyond flat fill/stroke, create a SkiaPaint:

import { SkiaPaint } from '@three-flatland/skia'
const paint = new SkiaPaint(skia)
.setFill()
.setLinearGradient(0, 0, 200, 0,
[0xFFFF0000, 0xFF0000FF], [0, 1])
rect.paint = paint // overrides inline fill/stroke props

In React, use useState initializer to create paints that survive strict mode:

const skia = useSkiaContext()!
const [paint] = useState(() => new SkiaPaint(skia).setFill())
useFrame(({ elapsed }) => {
// Animate the gradient each frame
paint.setLinearGradient(...)
})
return <skiaRect paint={paint} x={0} y={0} width={200} height={50} />

Create paths programmatically and perform boolean operations:

import { SkiaPath } from '@three-flatland/skia'
const a = new SkiaPath(skia).addCircle(100, 100, 40)
const b = new SkiaPath(skia).addCircle(130, 100, 40)
const result = new SkiaPath(skia)
a.opInto(b, 'intersect', result) // 'difference' | 'intersect' | 'union' | 'xor'
pathNode.path = result

Operations: difference, intersect, union, xor, reverse-difference.

attachSkiaTexture is an R3F attach helper that grabs .texture from a SkiaCanvas and assigns it to the parent material's map:

import { SkiaCanvas, attachSkiaTexture } from '@three-flatland/skia/react'
<meshBasicMaterial transparent premultipliedAlpha>
<SkiaCanvas attach={attachSkiaTexture} renderer={renderer}
width={1024} height={880}>
{/* Skia shapes rendered to texture */}
</SkiaCanvas>
</meshBasicMaterial>