Skip to content
kerfjsGitHub

Canvas

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

SourcePackage @kerf-js/veneer

Preview

tsx
import { Canvas } from '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-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 @kerf-js/veneer

Usage

tsx
import { Canvas } from '@kerf-js/veneer'<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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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, '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-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, '--veneer-color-text-subtle')  const { fontFamily } = getComputedStyle(ctx.canvas)  ctx.font = `13px ${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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-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, '--veneer-color-text-subtle')  const { fontFamily } = getComputedStyle(ctx.canvas)  ctx.font = `13px ${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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-color-surface-sunken',  )  ctx.beginPath()  ctx.arc(cx, cy, r, Math.PI, 0)  ctx.stroke()  ctx.strokeStyle = token(    ctx,    needle.at > 0.8      ? '--veneer-color-critical-solid'      : '--veneer-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, '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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)  const { fontFamily } = getComputedStyle(ctx.canvas)  ctx.font = `12px ${fontFamily}`  ctx.strokeStyle = token(ctx, '--veneer-color-border')  ctx.fillStyle = token(ctx, '--veneer-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,    '--veneer-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, '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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 veneer'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 = [  '--veneer-color-accent-solid',  '--veneer-color-positive-solid',  '--veneer-color-caution-solid',  '--veneer-color-critical-solid',  '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-color-surface-hover',  )  ctx.fillRect(0, 0, width, height)  ctx.fillStyle = token(    ctx,    '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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,    '--veneer-color-surface-sunken',  )  ctx.fillRect(0, 0, width, height)  ctx.fillStyle = token(    ctx,    '--veneer-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 '@kerf-js/veneer'import type { CanvasFrame } from '@kerf-js/veneer'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, '--veneer-color-info-solid'),  )  sky.addColorStop(    1,    token(ctx, '--veneer-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, '--veneer-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 Veneer 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 Veneer'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