Styling
Surfaces
A canvas or WebGL view that sits in layout like any other child. setup runs once on the client, props are live, and nothing around it re-renders. Never write a raw <canvas>.
import { state } from '@weft-js/core'import { defineSurface, Surface } from '@weft-js/core/surface'import { z } from 'zod'export const Bars = defineSurface({ name: 'bars', kind: 'canvas2d', // or 'webgl2' props: z.object({ data: z.array(z.number()) }), // or defineSurface<{ data: number[] }, 'canvas2d'>({ ... }) setup(s, props) { // runs once; `props.data` is live, so never destructure it s.draw(() => { // repaints when a box it read changes; the canvas is cleared first const { width, height } = s.size.value // CSS px, live s.ctx.fillStyle = s.tokens.accent.css // live theme colours: `.css`, or `.linear` for shaders props.data.forEach((v, i) => s.ctx.fillRect(i * 12, height - v, 10, v)) }) },})export function Page() { const rows = state([3, 7, 5]) return <Surface use={Bars} data={rows.value} aspect="16/9" label="Sales by month" />}A control beside the surface needs only a box and a live prop. Nothing re-renders when it moves:
import { state } from '@weft-js/core'import { defineSurface, Surface } from '@weft-js/core/surface'export const Stars = defineSurface<{ density: number }, 'canvas2d'>({ // name the kind too, so `s.ctx` is typed name: 'stars', kind: 'canvas2d', setup(s, props) { s.frame(({ seconds }) => { // every tick; reading `props.density` here keeps it live for (let i = 0; i < props.density; i++) s.ctx.fillRect((i * 37 + seconds * 20) % s.size.value.width, (i * 91) % s.size.value.height, 1, 1) }) },})export function Sky() { const density = state(100) return <> <input type="range" min="10" max="500" value={density.value} onInput={e => (density.value = e.currentTarget.valueAsNumber)} aria-label="Density" /> <Surface use={Stars} density={density.value} aspect="16/9" decorative /> </>}<Surface>: use, your props, aspect (default 2/1), label (required, X009) or decorative, class, style. The server renders a sized placeholder, so nothing shifts when the canvas mounts. Put pointer handlers on s.el.
s:
s.ctx(canvas2d),s.gl(webgl2),s.canvas,s.els.size,s.dpr,s.scale: boxes.canvas2ddraws in CSS px;webgl2has its viewport set for yous.tokens.accent:--accent, else--warp-accent, as{ css, linear }s.draw(fn): paint when a box it read changess.frame(t => ...): animate:t.frame,t.seconds,t.delta. Also repaints on a box change while pauseds.invalidate(): book a paint by hands.text(text, options): text; see belowreturn () => ...fromsetupto clean up
defineSurface also takes fps (default 60), maxDpr (default 2), clear: false, context (getContext attributes), and pixel: { width, height } for a fixed target with integer scale and nearest sampling.
The clock is shared: import { clock } from '@weft-js/core/surface', then clock.pause(), clock.play(), clock.seek(seconds). seek paints every animated surface now, so a capture is deterministic. Under prefers-reduced-motion: reduce it starts paused on the first frame. Off-screen surfaces skip their paints.
Text
Never fillText (X010). s.text() keeps a real element over the text, so selection, find, translation, screen readers, forced colours and print all work:
const score = s.text(() => `Score ${props.score}`, { x: 8, y: 8, width: 200, font: '600 16px Inter', lineHeight: 22 })- Options may be a function, for live positions:
() => ({ x: s.size.value.width / 2, ... }). fontandlineHeightdefault to the CSS on the mirror.colordefaults to the surface's.alignisleft,centerorright. Name a family (X002).- It returns
{ el, lineCount, height }; the last two are boxes. - Canvas
s.text()needs thetext.measureslot, so addtext()from@weft-js/texttopluginsin weft.config.ts (P101). Its engine loads lazily. Onwebgl2the mirror is the visible text, and no plugin is needed. s.decorative(() => ctx.fillText(...))is for text that is truly decoration.
Testing
jsdom has no canvas, so @weft-js/core/test gives it a recording one:
<!-- example: skip imports the Page above from its own file -->
import { canvasCalls, clock, fakeMeasure, flush, render } from '@weft-js/core/test'import { expect, test } from 'vitest'import { Page } from './Page' // the Bars page abovetest('draws a bar per value', async () => { clock.install() render(Page, {}, { slots: { 'text.measure': fakeMeasure() } }) await flush() await clock.advance(16) // a frame expect(canvasCalls(document.querySelector('canvas')!).filter(c => c.name === 'fillRect')).toHaveLength(3)})weft surfaces --json (MCP surfaces) lists what the project already has.