Skip to content
weftGitHub

Canvas

A canvas that handles sizing, sharp pixels, off-screen pausing and reduced motion for you.

SourcePackage @weft-js/warp

Preview

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Wave() {  return (    <Canvas draw={drawWave} animate style={STAGE} />  )}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawWave({ ctx, width, height, time }: CanvasFrame) {  ctx.clearRect(0, 0, width, height)  ctx.strokeStyle = token(ctx, '--warp-color-accent-solid')  ctx.lineWidth = 2  ctx.beginPath()  for (let x = 0; x <= width; x += 4) ctx.lineTo(x, height / 2 + Math.sin(x / 40 + time * 2) * Math.sin(x / 160 - time / 2) * height * 0.35)  ctx.stroke()}

Installation

npm install @weft-js/warp

Usage

tsx
import { Canvas } from '@weft-js/warp'<Canvas draw={drawWave} animate style={STAGE} />

One drawing

draw gets a scaled context. Resolve a token through CSS before you paint with it. Returning true asks for another frame.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Bars() {  return (    <Canvas draw={drawRequests} label="Requests a month, rising from 41 to 86 million" style={STAGE} />  )}const TRAFFIC = [  { month: 'Nov', requests: 41.2, errors: 0.42, deploys: 88, cost: 6120 },  { month: 'Dec', requests: 52.8, errors: 0.51, deploys: 74, cost: 6480 },  { month: 'Jan', requests: 47.1, errors: 0.38, deploys: 96, cost: 6390 },  { month: 'Feb', requests: 49.6, errors: 0.33, deploys: 104, cost: 6710 },  { month: 'Mar', requests: 58.3, errors: 0.47, deploys: 118, cost: 7240 },  { month: 'Apr', requests: 61.9, errors: 0.29, deploys: 126, cost: 7380 },  { month: 'May', requests: 66.4, errors: 0.31, deploys: 131, cost: 7690 },  { month: 'Jun', requests: 64.2, errors: 0.44, deploys: 122, cost: 7620 },  { month: 'Jul', requests: 71.5, errors: 0.27, deploys: 139, cost: 8010 },  { month: 'Aug', requests: 74.8, errors: 0.25, deploys: 144, cost: 8230 },  { month: 'Sep', requests: 79.3, errors: 0.36, deploys: 152, cost: 8560 },  { month: 'Oct', requests: 86.1, errors: 0.22, deploys: 167, cost: 8930 },]const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawRequests({ ctx, width, height, time, still }: CanvasFrame) {  const bar = width / TRAFFIC.length, grow = still ? 1 : Math.min(1, time / 0.8)  ctx.clearRect(0, 0, width, height)  ctx.fillStyle = token(ctx, '--warp-color-accent-solid')  TRAFFIC.forEach((t, i) => {    const h = (t.requests / 90) * height * grow    ctx.fillRect(i * bar + 3, height - h, bar - 6, h)  })  return grow < 1}

Pointer

pointer fills frame.pointer with where it is, whether it is over the canvas and whether it is pressed, and repaints when it moves.

Follows the pointer

Move across the dots.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Pointer() {  return (    <Canvas draw={drawSpotlight} pointer style={STAGE} />  )}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawSpotlight({ ctx, width, height, pointer }: CanvasFrame) {  ctx.clearRect(0, 0, width, height)  ctx.fillStyle = token(ctx, '--warp-color-text-subtle')  for (let x = 12; x < width; x += 24) {    for (let y = 12; y < height; y += 24) {      const near = pointer.in ? Math.max(0, 1 - Math.hypot(x - pointer.x, y - pointer.y) / 120) : 0      ctx.beginPath()      ctx.arc(x, y, 1.5 + near * 6, 0, Math.PI * 2)      ctx.fill()    }  }}

A fading trail

Each move adds a point; animate fades the old ones out. Keep per-canvas state in the component, not the module.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export const Trail = () => {  const trail: { x: number; y: number; at: number }[] = []  return <Canvas draw={f => drawTrail(f, trail)} animate pointer style={STAGE} />}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawTrail({ ctx, width, height, time, pointer }: CanvasFrame, trail: { x: number; y: number; at: number }[]) {  if (pointer.in) trail.push({ x: pointer.x, y: pointer.y, at: time })  while (trail.length && (time - trail[0]!.at > 1.2 || trail.length > 120)) trail.shift()  ctx.clearRect(0, 0, width, height)  ctx.strokeStyle = token(ctx, '--warp-color-accent-solid')  ctx.lineCap = 'round'  for (let i = 1; i < trail.length; i++) {    const age = (time - trail[i]!.at) / 1.2    ctx.globalAlpha = 1 - age    ctx.lineWidth = 2 + (1 - age) * 10    ctx.beginPath()    ctx.moveTo(trail[i - 1]!.x, trail[i - 1]!.y)    ctx.lineTo(trail[i]!.x, trail[i]!.y)    ctx.stroke()  }  ctx.globalAlpha = 1  ctx.fillStyle = token(ctx, '--warp-color-text-subtle')  ctx.font = `13px ${getComputedStyle(ctx.canvas).fontFamily}`  if (!trail.length) ctx.fillText('Move the pointer across', 16, 28)}

Press to ripple

pointer.down turns true on a press over the canvas. Compare it with the last frame to catch the press itself.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export const Ripples = () => {  const rings: { x: number; y: number; at: number }[] = []  const held = { down: false }  return <Canvas draw={f => drawRipples(f, rings, held)} animate pointer style={STAGE} />}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawRipples({ ctx, width, height, time, pointer }: CanvasFrame, rings: { x: number; y: number; at: number }[], held: { down: boolean }) {  if (pointer.down && !held.down) rings.push({ x: pointer.x, y: pointer.y, at: time })  held.down = pointer.down  while (rings.length && (time - rings[0]!.at > 1.6 || rings.length > 8)) rings.shift()  ctx.clearRect(0, 0, width, height)  ctx.strokeStyle = token(ctx, '--warp-color-info-solid')  ctx.lineWidth = 2  for (const r of rings) {    const t = (time - r.at) / 1.6    ctx.globalAlpha = 1 - t    for (const lag of [0, 0.15, 0.3]) {      ctx.beginPath()      ctx.arc(r.x, r.y, Math.max(0, t - lag) * 160, 0, Math.PI * 2)      ctx.stroke()    }  }  ctx.globalAlpha = 1  ctx.fillStyle = token(ctx, '--warp-color-text-subtle')  ctx.font = `13px ${getComputedStyle(ctx.canvas).fontFamily}`  if (!rings.length) ctx.fillText('Press anywhere', 16, 28)}

Eases, then stops

No animate: the needle returns true while it is still moving, so frames stop as soon as it arrives.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export const Needle = () => {  const needle = { at: 0 }  return <Canvas draw={f => drawNeedle(f, needle)} pointer label="A gauge that follows the pointer across it" style={STAGE} />}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawNeedle({ ctx, width, height, pointer, dt }: CanvasFrame, needle: { at: number }) {  const goal = pointer.in ? Math.min(1, Math.max(0, pointer.x / width)) : 0.35  needle.at += (goal - needle.at) * Math.min(1, dt * 8)  const cx = width / 2, cy = height - 24, r = Math.min(width / 2, height) - 40  ctx.clearRect(0, 0, width, height)  ctx.lineWidth = 12  ctx.strokeStyle = token(ctx, '--warp-color-surface-sunken')  ctx.beginPath()  ctx.arc(cx, cy, r, Math.PI, 0)  ctx.stroke()  ctx.strokeStyle = token(ctx, needle.at > 0.8 ? '--warp-color-critical-solid' : '--warp-color-accent-solid')  ctx.beginPath()  ctx.arc(cx, cy, r, Math.PI, Math.PI + needle.at * Math.PI)  ctx.stroke()  const a = Math.PI + needle.at * Math.PI  ctx.lineWidth = 3  ctx.strokeStyle = token(ctx, '--warp-color-text')  ctx.beginPath()  ctx.moveTo(cx, cy)  ctx.lineTo(cx + Math.cos(a) * (r - 18), cy + Math.sin(a) * (r - 18))  ctx.stroke()  return Math.abs(goal - needle.at) > 0.002}

Data and text

Text on a canvas takes the page's font through CSS, and a token's colour the same way.

fps={30} is plenty for a chart that scrolls, and costs half the frames.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Live() {  return (    <Canvas draw={drawLive} animate fps={30} label="p95 latency over the last twelve seconds" style={STAGE} />  )}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}const latency = (t: number) => 120 + Math.sin(t * 1.3) * 30 + Math.sin(t * 3.7) * 14 + Math.max(0, Math.sin(t * 0.4) * 60 - 40)function drawLive({ ctx, width, height, time }: CanvasFrame) {  const pad = 28, span = 12, max = 240, y = (v: number) => height - pad - (v / max) * (height - pad * 2)  ctx.clearRect(0, 0, width, height)  ctx.font = `12px ${getComputedStyle(ctx.canvas).fontFamily}`  ctx.strokeStyle = token(ctx, '--warp-color-border')  ctx.fillStyle = token(ctx, '--warp-color-text-subtle')  ctx.lineWidth = 1  for (const v of [0, 80, 160, 240]) {    ctx.beginPath()    ctx.moveTo(pad + 24, Math.round(y(v)) + 0.5)    ctx.lineTo(width, Math.round(y(v)) + 0.5)    ctx.stroke()    ctx.fillText(`${v}`, 4, y(v) + 4)  }  ctx.strokeStyle = token(ctx, '--warp-color-accent-solid')  ctx.lineWidth = 2  ctx.beginPath()  for (let x = pad + 24; x <= width; x += 3) ctx.lineTo(x, y(latency(time - span + ((x - pad - 24) / (width - pad - 24)) * span)))  ctx.stroke()  ctx.fillStyle = token(ctx, '--warp-color-text')  ctx.textAlign = 'right'  ctx.fillText(`p95 ${Math.round(latency(time))} ms`, width - 8, 16)  ctx.textAlign = 'left'}

Theme and dial

Colours kept until the theme changes

Read colours once and keep them. onTheme runs before the repaint when the theme or dial changes, so drop them there.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export const Themed = () => {  let inks: string[] | undefined  const draw = ({ ctx, width, height }: CanvasFrame) => {    inks ??= TONES_DRAWN.map(name => token(ctx, name))    ctx.clearRect(0, 0, width, height)    const r = Math.min(width, height) / 2 - 16    inks.forEach((ink, i) => {      ctx.strokeStyle = ink      ctx.lineWidth = 14      ctx.beginPath()      ctx.arc(width / 2, height / 2, r - i * 20, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (0.9 - i * 0.14))      ctx.stroke()    })  }  return <Canvas draw={draw} onTheme={() => (inks = undefined)} label="Five rings in warp's tones" style={STAGE} />}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}const TONES_DRAWN = ['--warp-color-accent-solid', '--warp-color-positive-solid', '--warp-color-caution-solid', '--warp-color-critical-solid', '--warp-color-info-solid']

Reads the expression dial

frame.tier carries the dial's numbers where the canvas sits: more dots and faster at vivid, fewer at quiet.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Orbits() {  return (    <Canvas draw={drawOrbits} animate style={STAGE} />  )}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawOrbits({ ctx, width, height, time, tier }: CanvasFrame) {  const count = Math.round(4 + tier.reach * 14), cx = width / 2, cy = height / 2  ctx.clearRect(0, 0, width, height)  ctx.fillStyle = token(ctx, '--warp-color-accent-solid')  for (let i = 0; i < count; i++) {    const ring = 30 + (i % 4) * 22, a = time * (0.3 + tier.speed) * (i % 2 ? 1 : -0.7) + (i / count) * Math.PI * 2    ctx.beginPath()    ctx.arc(cx + Math.cos(a) * ring * 1.8, cy + Math.sin(a) * ring, 3 + (i % 3), 0, Math.PI * 2)    ctx.fill()  }}

Pixels

Whole cells

cell draws in big square pixels. fps slows the clock.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Cells() {  return (    <Canvas draw={drawCells} animate fps={12} cell={8} style={STAGE} />  )}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawCells({ ctx, width, height, time }: CanvasFrame) {  ctx.fillStyle = token(ctx, '--warp-color-surface-hover')  ctx.fillRect(0, 0, width, height)  ctx.fillStyle = token(ctx, '--warp-color-accent-solid')  for (let x = 0; x < width; x++) {    const top = Math.floor(height / 2 + Math.sin(x / 6 + time) * height * 0.3)    ctx.fillRect(x, top, 1, height - top)  }}

Game of Life

In cell mode width and height count cells, so one cell is one fillRect(x, y, 1, 1).

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export const Life = () => {  const life = { cells: new Uint8Array(0), w: 0, h: 0, seed: 1 }  return <Canvas draw={f => drawLife(f, life)} animate fps={8} cell={6} label="Conway's Game of Life" style={STAGE} />}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawLife({ ctx, width, height }: CanvasFrame, life: { cells: Uint8Array; w: number; h: number; seed: number }) {  if (life.w !== width || life.h !== height || !life.cells.includes(1)) {    life.w = width    life.h = height    life.cells = Uint8Array.from({ length: width * height }, (_, i) => ((Math.imul(i + life.seed++, 2654435761) >>> 0) % 7 < 2 ? 1 : 0))  }  const next = new Uint8Array(width * height)  for (let y = 0; y < height; y++) {    for (let x = 0; x < width; x++) {      let n = 0      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx || dy) n += life.cells[((y + dy + height) % height) * width + ((x + dx + width) % width)]!      next[y * width + x] = n === 3 || (n === 2 && life.cells[y * width + x]) ? 1 : 0    }  }  life.cells = next  ctx.fillStyle = token(ctx, '--warp-color-surface-sunken')  ctx.fillRect(0, 0, width, height)  ctx.fillStyle = token(ctx, '--warp-color-positive-solid')  for (let i = 0; i < next.length; i++) if (next[i]) ctx.fillRect(i % width, Math.floor(i / width), 1, 1)}

An opaque picture

context={{ alpha: false }} for a drawing that covers every pixel: the page skips blending it.

tsx
import { Canvas } from '@weft-js/warp'import type { CanvasFrame } from '@weft-js/warp'export function Night() {  return (    <Canvas draw={drawNight} context={{ alpha: false }} label="Stars over a ridge" style={STAGE} />  )}const STAGE = 'block-size:240px'const token = (ctx: CanvasRenderingContext2D, name: string) => {  ctx.canvas.style.color = `var(${name})`  return getComputedStyle(ctx.canvas).color}function drawNight({ ctx, width, height }: CanvasFrame) {  const sky = ctx.createLinearGradient(0, 0, 0, height)  sky.addColorStop(0, token(ctx, '--warp-color-info-solid'))  sky.addColorStop(1, token(ctx, '--warp-color-accent-solid'))  ctx.fillStyle = sky  ctx.fillRect(0, 0, width, height)  let seed = 7  const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)  ctx.fillStyle = '#fff'  for (let i = 0; i < 160; i++) {    ctx.globalAlpha = 0.3 + rand() * 0.7    ctx.fillRect(rand() * width, rand() * height * 0.75, rand() < 0.1 ? 2 : 1, rand() < 0.1 ? 2 : 1)  }  ctx.globalAlpha = 1  ctx.fillStyle = token(ctx, '--warp-color-surface')  ctx.beginPath()  ctx.moveTo(0, height)  for (let x = 0; x <= width; x += 8) ctx.lineTo(x, height - 40 - Math.sin(x / 70) * 18 - Math.sin(x / 23) * 6)  ctx.lineTo(width, height)  ctx.fill()}

When to use

Use it for

  • You need to draw something warp has no component for

Reach for something else

  • DitherField, when a textured background is enough
  • BarChart, when it is data people need to read exactly

Accessibility

  • With label it is an image with that name. Without one it is decorative and hidden from screen readers.
  • Canvas has a size of zero until its container has one, so set a height.

API Reference

PropTypeDefaultDescription
draw(frame: Frame) => boolean | void—Paints one frame. Read colours from warp's tokens so the theme applies.
animatebooleanfalseMoves at balanced and vivid. Quiet and reduced motion paint one frame, with still set.
fpsnumber—Caps the frame rate while it animates. Uncapped by default, at the display's rate.
cellnumber—Whole-cell mode: width and height count cells of this many CSS pixels, scaled up without smoothing.
contextCanvasRenderingContext2DSettings—Settings for the 2D context, such as { alpha: false }. Read once, when the canvas attaches.
pointerbooleanfalseTrack the pointer into frame.pointer, and repaint when it moves over the canvas.
labelstring—Names the drawing (role="img"). Without one it is decorative.
EventTypeDefaultDescription
onTheme(() => void)—Called when the theme changes. Refresh any colours you keep here.

RelatedCanvas stageDither fieldDither