npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

meo-canvas

v9.0.2

Published

A declarative, component-based library for server-side canvas image generation. Write complex visuals with simple functions, similar to the composition style of @meonode/ui.

Readme

meo-canvas

npm CI node types license

A declarative, component-based library for server-side canvas image generation. Write complex visuals with simple functions, similar to the composition style of @meonode/ui. It uses meo-skia-canvas for drawing and yoga-layout for flexbox-based layouts.

This library allows you to build complex image layouts using a familiar component-based approach. You can define your image structure with components like Box, Text, Image, Path, and Grid, and the library will handle the layout and rendering to a canvas.

Contents

Key Features

  • Declarative API: Build images using a component tree, just like in React.
  • Flexbox Layout: Powered by yoga-layout, it supports flexbox for powerful and flexible layouts.
  • Rich Text: Render text with custom fonts and inline styling using simple HTML-like tags. Supported tags include <color="value">, <weight="value">, <size="value">, <b>, and <i>.
  • Image Support: Render images from URLs, file paths, or buffers, with object-fit and object-position support. Animated sources play at their own rate.
  • Chart Support: Render bar, line, pie, and doughnut charts with customizable data and options.
  • Styling: Style your components with properties that mimic CSS, including borders, padding, margins, and more.
  • Grid Layout: A Grid component is provided for easy grid-based layouts.
  • CSS Positioning: Static, Relative, Absolute, Fixed and Sticky, with CSS's containing blocks, painting order and stacking contexts — not Yoga's.
  • Arbitrary Shapes: A Path component draws SVG path data — the escape hatch for what the components cannot describe.
  • Masking: Cut any node to a shape, a path, or a gradient's alpha.
  • Animated Output: Render a sequence with one page per frame and export GIF, APNG, animated WebP or AVIF — or PDF and TIFF sheets from the same tree.
  • Animation Utilities: Easings, springs solved in closed form, and track/sequence/parallel for composing them. Colours interpolate in every format the engine parses.
  • Engine Control: Choose the GPU or CPU backend, the pixel format and the colour space.
  • Dithering: dither breaks up the banding a long, subtle gradient shows on an eight-bit surface. Set it on Root for the page, or on any node for its own subtree.
  • CSS Effects: filter and backdropFilter take a CSS filter chain — the node's own picture, or the one behind it — and mixBlendMode carries all sixteen blend modes. Each applies to a subtree as one picture, the way CSS does. See Effects.
  • Outlined Text: textStroke draws -webkit-text-stroke, centred on the glyph and over the fill as a browser draws it; paintOrder puts it underneath to keep the letterform whole.
  • Backgrounds: backgroundImage tiles a picture behind a node with every CSS background-repeat mode, space and round included, and gradient sweeps conically as well as linearly and radially.
  • Browser-matching text: Line boxes are built the way CSS builds them — baselines land within 0.15px of Chrome — with textAlign, verticalAlign, textDecoration and overflow following the same rules.
  • TypeScript Support: Fully typed for a better development experience.
  • API reference → — generated from the source of each release.
  • Architecture →

Showcase

Installation

bun add meo-canvas

No trustedDependencies entry is needed, on bun or anywhere else. meo-skia-canvas ships its native binary as one optional dependency per platform, selected by os/cpu/libc, so nothing has to run an install script for the renderer to work.

Node 22 or newer, and ESM onlyimport, not require. That is not a preference: the layout engine, yoga-layout, awaits its WebAssembly module at the top level of its entry, and require() refuses an ESM graph containing a top-level await on every version of Node. A CommonJS file can still reach the library through a dynamic import:

// Top-level `await` is not available in CommonJS, so the import is consumed in a callback.
import('meo-canvas').then(({ Root, Box, Text }) => {
  // ...
})

Usage

Simple Example

A minimal example that renders a title and description to a PNG file:

import { Root, Box, Text } from 'meo-canvas'

async function generateImage() {
  const canvas = await Root({
    width: 500,
    height: 300,
    scale: 1, // increase to 2 for 2× retina resolution
    workerMode: true, // renders in a worker thread — keeps the main thread free (default)
    fonts: [
      {
        family: 'Roboto',
        paths: ['./fonts/Roboto-Regular.ttf', './fonts/Roboto-Bold.ttf'],
      },
    ],
    children: [
      Box({
        width: '100%',
        height: '100%',
        backgroundColor: '#f0f0f0',
        padding: 20,
        children: [
          Text('Hello, World!', {
            fontSize: 32,
            fontWeight: 'bold',
            fontFamily: 'Roboto',
            color: '#333',
          }),
          Text('This is a basic example of using meo-canvas.', {
            fontSize: 18,
            fontFamily: 'Roboto',
            color: '#666',
            margin: { Top: 10 },
          }),
        ],
      }),
    ],
  })

  await canvas.toFile('output.png') // saves directly to disk
  canvas.release() // free worker memory after use
}

generateImage().catch(console.error)

Complex Layout

A more complete example using Column, Row, Image, and advanced flexbox properties to build a structured page layout with a header, content area, and footer:

import { Root, Column, Row, Text, Image, Style } from 'meo-canvas'

async function generateComplexImage() {
  const canvas = await Root({
    width: 800,
    height: 600,
    scale: 2, // 2× resolution — canvas output will be 1600×1200px
    workerMode: true, // renders off the main thread (default)
    useDiskCache: true, // caches fetched remote images to disk for faster re-decode
    fonts: [
      {
        family: 'Roboto',
        paths: ['./fonts/Roboto-Regular.ttf', './fonts/Roboto-Bold.ttf'],
      },
      {
        family: 'Open Sans',
        paths: ['./fonts/OpenSans-Regular.ttf'],
      },
    ],
    children: [
      Column({
        width: '100%',
        height: '100%',
        backgroundColor: '#f0f0f0',
        padding: 20,
        justifyContent: Style.Justify.SpaceBetween, // evenly space header, body, footer
        children: [
          // Header: avatar + title side by side
          Row({
            width: '100%',
            alignItems: Style.Align.Center,
            margin: { Bottom: 20 },
            children: [
              Image({
                src: 'https://via.placeholder.com/80x80/FF0000/FFFFFF?text=Logo',
                width: 80,
                height: 80,
                borderRadius: 40, // circle crop
                margin: { Right: 20 },
                objectFit: 'cover',
              }),
              Text('Welcome to MeoNode Canvas!', {
                fontSize: 40,
                fontWeight: 'bold',
                fontFamily: 'Roboto',
                color: '#333',
              }),
            ],
          }),

          // Body: grows to fill remaining vertical space
          Column({
            flexGrow: 1,
            width: '100%',
            backgroundColor: '#ffffff',
            borderRadius: 10,
            padding: 30,
            boxShadow: { blur: 10, color: 'rgba(0,0,0,0.1)' },
            children: [
              Text('A New Way to Render Graphics', {
                fontSize: 28,
                fontWeight: 'bold',
                fontFamily: 'Open Sans',
                color: '#555',
                margin: { Bottom: 15 },
              }),
              Text(
                `This example demonstrates a more complex layout using various components.
        We have a header with a logo and title, a main content area with text,
        and a footer. Notice how flexbox properties are used to arrange elements.`,
                {
                  fontSize: 18,
                  fontFamily: 'Open Sans',
                  color: '#777',
                  lineHeight: 24,
                },
              ),
              Image({
                src: 'https://via.placeholder.com/600x200/007bff/ffffff?text=Feature+Image',
                width: '100%',
                height: 200,
                margin: { Top: 20 },
                borderRadius: 8,
                objectFit: 'contain',
                objectPosition: { Top: '50%', Left: '50%' }, // center within box
              }),
            ],
          }),

          // Footer: centered copyright line
          Row({
            width: '100%',
            margin: { Top: 20 },
            justifyContent: Style.Justify.Center,
            children: [
              Text('© 2025 MeoNode Canvas. All rights reserved.', {
                fontSize: 14,
                fontFamily: 'Open Sans',
                color: '#999',
              }),
            ],
          }),
        ],
      }),
    ],
  })

  await canvas.toFile('complex_output.png') // saves directly to disk
  canvas.release() // free worker memory after use
}

generateComplexImage().catch(console.error)

Examples

Charts

The Chart component supports bar, line, pie, and doughnut chart types.

Bar Chart

import { Root, Chart } from 'meo-canvas'

async function generateBarChart() {
  const canvas = await Root({
    width: 600,
    height: 400,
    workerMode: true, // default — render in a worker thread
    children: [
      Chart({
        type: 'bar',
        width: '100%',
        height: '100%',
        data: {
          labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
          datasets: [
            {
              label: 'Sales',
              data: [120, 150, 180, 90, 200],
              color: '#36A2EB',
            },
          ],
        },
        options: {
          grid: { show: true, style: 'dashed' },
          axisColor: '#333',
          labelColor: '#333',
          showValues: true, // display value labels above each bar
          valueFontSize: 12,
          showYAxis: true, // show Y-axis tick labels on the left
          yAxisColor: '#666',
        },
      }),
    ],
  })

  await canvas.toFile('bar_chart.png')
  canvas.release()
}

generateBarChart().catch(console.error)

Doughnut Chart with Custom Legend

import { Root, Chart, Row, Box, Text, Style } from 'meo-canvas'

async function generateDoughnutChart() {
  const canvas = await Root({
    width: 600,
    height: 400,
    workerMode: true, // default — render in a worker thread
    children: [
      Chart({
        type: 'doughnut',
        width: '100%',
        height: '100%',
        data: [
          { label: 'Red', value: 300, color: '#FF6384' },
          { label: 'Blue', value: 50, color: '#36A2EB' },
          { label: 'Yellow', value: 100, color: '#FFCE56' },
        ],
        options: {
          innerRadius: 0.7, // 0 = full pie, 1 = empty ring; 0.7 gives a thick doughnut
          sliceBorderRadius: 5, // rounded corners on each slice
          // custom legend item: colored dot + "Label: value" text
          renderLegendItem: ({ item, color }) =>
            Row({
              alignItems: Style.Align.Center,
              children: [
                Box({ width: 12, height: 12, backgroundColor: color, borderRadius: 6 }),
                Text(`${item.label}: ${item.value}`, { fontSize: 16, margin: { Left: 8 } }),
              ],
            }),
        },
      }),
    ],
  })

  await canvas.toFile('doughnut_chart.png')
  canvas.release()
}

generateDoughnutChart().catch(console.error)

Grid

The Grid component simplifies creating complex layouts. It mimics CSS Grid Layout.

Basic Grid

A simple grid with 3 columns, each 100 pixels wide.

import { Root, Grid, Box, Text } from 'meo-canvas'

async function generateBasicGrid() {
  const canvas = await Root({
    width: 400,
    height: 300,
    workerMode: true, // default — render in a worker thread
    children: [
      Grid({
        columns: 3,
        templateColumns: [100, 100, 100], // fixed widths; also accepts ['100px', '100px', '100px']
        gap: 10,
        children: [
          Box({ backgroundColor: 'red', height: 50, children: [Text('1')] }),
          Box({ backgroundColor: 'blue', height: 50, children: [Text('2')] }),
          Box({ backgroundColor: 'green', height: 50, children: [Text('3')] }),
          Box({ backgroundColor: 'yellow', height: 50, children: [Text('4')] }), // wraps to row 2
        ],
      }),
    ],
  })

  await canvas.toFile('grid_basic.png')
  canvas.release()
}

generateBasicGrid().catch(console.error)

Responsive Grid (Fractional Units)

Using fractional units (fr) allows columns to take up proportional space.

Grid({
  // First column takes 1 part, second takes 2 parts, third takes 1 part
  templateColumns: ['1fr', '2fr', '1fr'],
  gap: 10,
  children: [
    Box({ backgroundColor: 'red', height: 50, children: [Text('1fr')] }),
    Box({ backgroundColor: 'blue', height: 50, children: [Text('2fr')] }),
    Box({ backgroundColor: 'green', height: 50, children: [Text('1fr')] }),
  ],
})

Spanning Items

Use GridItem (or pass gridColumn/gridRow props directly to any child) to span multiple columns or rows.

import { Grid, GridItem, Box, Text } from 'meo-canvas'

Grid({
  templateColumns: ['1fr', '1fr', '1fr'],
  gap: 10,
  children: [
    // Spans all 3 columns
    GridItem({
      gridColumn: 'span 3',
      height: 50,
      backgroundColor: '#333',
      children: [Text('Header', { color: 'white' })],
    }),
    // Standard items
    Box({ backgroundColor: '#eee', height: 100, children: [Text('Content')] }),
    Box({ backgroundColor: '#ccc', height: 100, children: [Text('Sidebar')] }),
    // Spans 2 columns
    GridItem({
      gridColumn: 'span 2',
      height: 50,
      backgroundColor: '#555',
      children: [Text('Footer', { color: 'white' })],
    }),
  ],
})

Yoga Layout

This library leverages yoga-layout for its powerful flexbox engine. Many layout properties directly map to Yoga's concepts. You can access Yoga-specific constants through the Style export from meo-canvas.

import { Box, Style } from 'meo-canvas'

Box({
  flexDirection: Style.FlexDirection.Row,
  justifyContent: Style.Justify.Center,
  alignItems: Style.Align.Center,
  children: [
    Box({
      width: 100,
      height: 100,
      backgroundColor: 'red',
      positionType: Style.PositionType.Absolute,
      position: { Top: 10, Left: 10 },
    }),
    // ... other children
  ],
})

Refer to the Yoga Layout documentation for a comprehensive understanding of these properties.

API Reference

What follows covers the props and methods you reach for most. Every exported symbol carries a doc comment, so the complete generated reference — every type, every option, every overload — lives at l7aromeo.github.io/meo-canvas/latest, and your editor shows the same text on hover.

Each release is published at its own address — /v8.0.0/, /v7.1.0/ and so on — and the index lists them. latest follows the newest published version, never whatever is on main, so a link to it always describes something you can actually install.

Root

The Root function is the entry point for rendering. It returns a Canvas object. It is a specialized ColumnNode that inherits all BoxProps.

Root Props

| Prop | Type | Default | Description | | ------------------ | ------------------------------------------------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------- | | width | number | - | Required. Width of the canvas in pixels. | | height | number | - | Optional height of the canvas. If not set, it's calculated from content. | | children | CanvasElement \| CanvasElement[] \| (page: PageInfo) => Children | - | Required. The component tree to render. Pass a function to render a sequence — one page per call. | | pages | number | - | Pages to render. Needs a children function; mutually exclusive with duration. | | duration | number | - | Sequence length in seconds; pages become ceil(duration * fps). Needs a children function. | | fps | number | 30 | Rate used to derive duration and PageInfo.time. Describes the render, not the encode. | | scale | number | 1 | Scale factor for rendering (e.g., 2 for 2x resolution). | | fonts | FontRegistrationInfo[] | - | An array of font files to register for use in the canvas. | | useDiskCache | boolean | false | Write fetched images to disk during render for faster re-decode. Entries are cleaned up after render. | | imageConcurrency | number | 5 | Maximum number of images to fetch concurrently during render. | | workerMode | boolean | true | Enable worker thread rendering for non-blocking operation. | | workers | number | cpus().length - 1 | Number of worker threads to use (only applies on first render withworkerMode: true). |

Since Root extends BoxProps, it also accepts backgroundColor, padding, gradient, boxShadow, and all other layout props, dither among them. See Box, Row, and Column for the full list.

Choosing the engine

Three props reach the canvas itself rather than the layout, and the rendered canvas reports what the engine settled on through gpu, engine, colorType and colorSpace.

| Prop | Type | Default | Description | | ------------ | ------------ | -------- | --------------------------------------------------------------------------------------- | | gpu | boolean | true | Rasterize on the GPU when one is available. false forces the CPU. | | colorType | ColorType | 'rgba' | Pixel format the canvas composites in — the precision everything is drawn at. | | colorSpace | ColorSpace | 'srgb' | Space colours are interpreted in; anything outside its gamut is clipped as it is drawn. |

// Identical output on every machine: GPU and CPU rasterizers resolve anti-aliased edges a level or
// two apart, which a pixel comparison sees.
await Root({ width: 600, gpu: false, children: [...] })

// Sixteen-bit PNG, and colour outside sRGB kept rather than clipped as it is drawn.
await Root({ width: 600, colorType: 'RGBAF32', colorSpace: 'display-p3', children: [...] })

Asking is not getting. These are requests: a build without GPU support, a driver that declines, and any float colorType all fall back to the CPU. Read the result rather than assuming it:

const canvas = await Root({ width: 600, colorType: 'RGBAF32', gpu: true, children: [...] })
canvas.gpu // false — no GPU composites float
canvas.colorType // 'RGBAF32'
canvas.engine.renderer // 'CPU'

colorType is the one with a cost attached. A float canvas is two to four times the memory, and while translucent layers are actually faster in float, opaque fills are not — RGBAF32 runs them several times slower. Reach for it when you need the depth or the gamut, not by default. Masks and shadows composite through offscreen canvases that inherit these settings, so a float render stays float all the way through.

Effects: filter, backdropFilter and mixBlendMode

Three properties that work on a node's picture rather than its layout. Each takes the subtree as a whole, which is what CSS does and is the part worth knowing:

Box({ filter: 'grayscale(1) brightness(1.1)', children: [...] })

// Frosted glass: the backdrop is filtered, then this node's own background paints over the result.
Box({
  backdropFilter: 'blur(12px) saturate(1.4)',
  backgroundColor: 'rgba(255,255,255,0.15)',
  borderRadius: 24,
})

Box({ backgroundColor: '#0af', mixBlendMode: Style.BlendMode.Multiply })

A subtree is one picture. Two overlapping children under one filter are composited first and filtered together, not filtered separately and then composited — the difference shows wherever a filter clamps or a child is translucent. mixBlendMode blends the finished subtree with what is behind it for the same reason, and opacity fades the filtered result rather than filtering a faded one.

filter takes the ten CSS functions: blur, brightness, contrast, drop-shadow, grayscale, hue-rotate, invert, opacity, saturate and sepia. saturate on an Image is a shorthand for the same machinery and runs first where both are given. A blur or a drop shadow reaches past the node's box, as it does in CSS, and is not clipped to it.

Only what has already been painted counts as a backdrop: a sibling declared after a backdropFilter node paints over it and is not included, and neither is the node's own background.

Background pictures: backgroundImage

A picture behind a node's content, placed by the CSS properties that place one. It never affects layout — the box is whatever the box was:

Box({ width: 400, height: 200, backgroundImage: { src: 'texture.png' } })

Box({
  backgroundImage: {
    src: 'hero.jpg',
    size: Style.BackgroundSize.Cover,
    repeat: Style.BackgroundRepeat.NoRepeat,
  },
})

// Whole tiles with the leftover space shared out between them.
Box({ backgroundImage: { src: 'dot.svg', size: 12, repeat: Style.BackgroundRepeat.Space } })

The source is fetched and decoded before layout, in the same pass and through the same cache as an Image — so a picture used as one node's background and another's image is loaded once, and both share the render's imageConcurrency. A source that fails to load costs the node its background rather than the render.

Every CSS repeat mode is supported. space fits whole tiles and shares the remainder out as equal gaps, pinning the first and last to the edges; round scales the tile instead, so a whole number of them fills the box exactly.

Smoothing gradients: dither

A long, subtle gradient bands on an eight-bit surface, because there are not enough values between its endpoints to fill the distance. A ramp from #0b1220 to #1e2b4a across 400px has 42 blue levels to spend, which is a visible step every 19 pixels. More colour stops cannot help — the values do not exist.

dither spreads each step over neighbouring pixels instead, so the eye averages them back into the tone that was meant:

// The whole page.
await Root({ width: 800, dither: true, children: [...] })

// Or one subtree, which overrides whatever the page said.
Box({ dither: false, children: [...] })

Unlike gpu, colorType and colorSpace, this is not a property of the canvas: it is inherited down the tree, so a node takes its nearest ancestor's answer and a node that sets its own leaves its siblings untouched. Masks carry it onto the offscreen they composite through.

It costs only what it fixes. A flat fill, text and a blurred shadow encode to identical bytes either way — a dither only perturbs a pixel whose colour falls between two the surface can hold. Measured on a 800×400 card with a gradient background, text and shapes:

| Format | Undithered | Dithered | | ------ | ---------- | -------- | | PNG | 10,672 B | 14,387 B | | WebP | 7,728 B | 7,808 B |

Lossy encoders absorb the noise almost entirely; PNG pays about a third more across the gradient itself.

A float colorType is the other answer, and the two do not combine — RGBAF16 has the precision to draw the ramp outright and exports it through a sixteen-bit PNG, with no noise at all. It also forces the CPU backend and costs several times the memory, and most delivery formats are eight-bit regardless, so dither is the one that applies to ordinary output.

Multi-page and Animated Output

A page is a frame for gif, apng, webp and avif, a sheet for pdf and tiff, and a size for ico. Pass a function as children to render a sequence — it runs once per page.

const canvas = await Root({
  width: 200,
  height: 200,
  duration: 1.5, // 36 pages at 24fps
  fps: 24,
  children: ({ progress }) =>
    Box({
      width: 40 + progress * 120,
      height: 40 + progress * 120,
      borderRadius: 999,
      borderWidth: 6,
      borderColor: `hsl(${Math.round(progress * 320)}, 90%, 60%)`,
    }),
})

await canvas.toBuffer('gif', { fps: 24, loop: 0 })

The function receives a PageInfo:

| Field | Type | Description | | ---------- | -------- | ------------------------------------------------------------------------------------------------------ | | index | number | Zero-based position in the sequence. | | count | number | Total pages in this render. | | progress | number | 0 on the first page, 1 on the last. Use for one-shot interpolation and easing. | | cycle | number | 0 on the first page, approaching 1 on the last without reaching it. Use for anything that repeats. | | time | number | Seconds elapsed, index / fps. Use for physics or spring integration. |

The function may be async, so a page can await its own data. Use pages: n instead of duration when the count matters more than the timing — a three-page PDF is pages: 3.

Looping: reach for cycle, not progress

progress spans the sequence inclusively, which is what a one-shot animation wants — it should finish on its end value on the frame the viewer stops on. Anything periodic wants the opposite, because 1 and 0 are the same point on a circle:

Math.sin(progress * 2 * Math.PI) // the last page repeats page 0 — one frame stands still on every loop
Math.sin(cycle * 2 * Math.PI) // the last page is one step short of the start — the loop closes seamlessly

The stutter is invisible frame by frame and only shows on the wrap, which is what makes it worth knowing about before you ship it. time shares cycle's half-open span ([0, duration)), so time-driven periodic motion was already seamless.

Every page must be the same size for gif, apng and tiff, so an animated render needs an explicit height — without one each page sizes itself to its own content and the encoder rejects the mismatch. pdf is the exception: it genuinely allows a different size per page, which is why height stays optional.

The animated card in the Showcase is built this way — staggered bars easing to their values, with no keyframes anywhere. See scripts/generate_sample_animated_card.ts.

Canvas Methods

The Root() function returns a Canvas object with the following methods and properties.

Export Methods

Animation timing — fps, loop, frameDelays — is accepted only by gif, apng, webp and avif. Passing it to any other format is a compile error, matching the renderer, which raises a TypeError rather than dropping it silently.

page picks one page and pageRange takes a span of them; every format that gathers pages accepts both. See Exporting part of a sequence.

| Method | Signature | Description | | --------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------- | | toBuffer | (format: ExportFormat, options?: ExportOptions) => Promise<Buffer> | Encodes to the given format. Preferred — see the note below. | | toBufferSync | (format?: ExportFormat, options?: ExportOptions) => Buffer | Same, blocking the calling thread until the encode finishes. | | toURL | (format: ExportFormat, options?: ExportOptions) => Promise<string> | Returns a data URL. | | toURLSync | (format?: ExportFormat, options?: ExportOptions) => string | Blocking data URL. | | toDataURL | (format?: ExportFormat, quality?: number) => string | Blocking data URL, with a 01 quality shorthand. | | toFile | (filename: string, options?: SaveOptions) => Promise<void> | Saves the canvas to a file. | | toFileSync | (filename: string, options?: SaveOptions) => void | Blocking file write. | | toSharp | (options?: RenderOptions) => Sharp | A Sharp instance for further processing. Requires sharp. | | toSharpSync | (options?: RenderOptions) => Sharp | Identical to toSharp(); both build the Sharp on this thread. | | saveAs | (filename: string, options?: SaveOptions) => Promise<void> | Deprecated — use toFile(). | | saveAsSync | (filename: string, options?: SaveOptions) => void | Deprecated — use toFileSync(). | | toDataURLSync | (format?: ExportFormat, options?: ExportOptions) => string | Deprecated — use toDataURL(). |

Supported export formats — every format the renderer encodes, since these types partition its own ExportFormat rather than restating it:

| Format | Kind | Notes | | -------------- | --------- | ------------------------------------------------------------------------ | | png | still | Lossless. 16-bit from a float or 16-bit canvas. | | jpg / jpeg | still | Lossy; takes quality. Aliases for the same encoder. | | webp | both | One page is a still, several are an animation. | | gif | animation | 256 colours; delays round to hundredths of a second. | | apng | animation | Truecolour with alpha. Each frame carries only the rectangle it changed. | | avif | animation | Also encodes a single page. Takes bitDepth. | | tiff / tif | sheets | Gathers every page into one file. Aliases. | | ico | sizes | Each page is one icon size. | | bmp | still | Uncompressed. | | pdf | sheets | The one format whose pages may differ in size. | | svg | still | Vector. outline: true converts text to paths. | | raw | still | Pixel data in the canvas's own colorType. |

Prefer the async methods in worker mode. Both produce identical bytes, but toBuffer() runs the encode off the event loop, while toBufferSync() blocks the calling thread for its whole duration — the same way a synchronous method on a plain Canvas does. A sync call also queues behind whatever its worker is currently rendering, because a Canvas is native memory pinned to the thread that drew it.

Repeated sync calls for the same format and options are served from a cache, so asking twice costs one encode.

Convenience Getters

| Getter | Returns | Description | Non-worker | | ------- | ----------------- | ----------------------------------------------- | ---------- | | .png | Promise<Buffer> | Shortcut for toBuffer('png') | yes | | .jpg | Promise<Buffer> | Shortcut for toBuffer('jpg') | yes | | .webp | Promise<Buffer> | Shortcut for toBuffer('webp') | yes | | .svg | Promise<Buffer> | Shortcut for toBuffer('svg') | yes | | .pdf | Promise<Buffer> | Shortcut for toBuffer('pdf') | yes | | .raw | Promise<Buffer> | Shortcut for toBuffer('raw') — raw pixel data | yes | | .gif | Promise<Buffer> | Shortcut for toBuffer('gif') | — | | .apng | Promise<Buffer> | Shortcut for toBuffer('apng') | — | | .avif | Promise<Buffer> | Shortcut for toBuffer('avif') | — | | .tiff | Promise<Buffer> | Shortcut for toBuffer('tiff') | — | | .ico | Promise<Buffer> | Shortcut for toBuffer('ico') | — | | .bmp | Promise<Buffer> | Shortcut for toBuffer('bmp') | — |

The last six exist only on a worker-mode canvas. A non-worker render hands back the renderer's own Canvas, which carries the first six and no others — so canvas.gif is a compile error there, and toBuffer('gif') is the portable spelling. Every format works in both modes; only the shorthand differs.

Canvas Properties

| Property | Type | Description | | --------- | --------------- | ------------------------------------------------ | | .width | number | Canvas width in pixels (after scale). | | .height | number | Canvas height in pixels (after scale). | | .gpu | boolean | Whether the render used the GPU. | | .engine | EngineDetails | Renderer, graphics API, device and thread count. |

Not available in worker mode

getContext(), newPage() and pages each hand back a live rendering context bound to native memory inside the worker, which cannot cross a thread boundary — proxying one would mean a round trip per drawing call. They throw in worker mode. Use Root({ workerMode: false }) if you need to drive a context directly; drawing is otherwise expressed as a component tree.

Memory Management (Worker Mode)

| Method | Description | | ------------ | ------------------------------------------------------------------------------------------------------------ | | .release() | Required in worker mode. Releases the Canvas from worker memory. Call when done to prevent memory leaks. |

import {Root} from 'meo-canvas'

// Render with default worker mode (enabled)
const canvas = await Root({width: 400, height: 400, children: [...]})

// Or explicitly disable worker mode
const canvas = await Root({width: 400, height: 400, children: [...], workerMode: false})

// Use the canvas
const png = await canvas.png
const jpg = await canvas.jpg
await canvas.toFile('output.png')

// Release memory (worker mode only)
canvas.release()

Release in a finally if anything between render and export can throw, or the canvas is stranded on the error path:

const canvas = await Root({width: 400, height: 400, children: [...]})
try {
  return await canvas.toBuffer('webp')
} finally {
  canvas.release()
}

Note: A FinalizationRegistry is wired up as a backstop, but do not rely on it. The memory it guards is native, so it creates no pressure on the garbage collector and the callback may never fire: 400 renders without an explicit release grew RSS from 247 MB to 677 MB with no plateau, even forcing a collection every round. The same 400 renders releasing explicitly settle flat.


Animation Utilities

Everything below is a pure function of the page, so it can be called for any page in any order and never carries state between them.

Tracks

A track declares one animation and is sampled per page. It works in seconds, which is what duration and fps already speak.

import { Root, Box, track } from 'meo-canvas'

const grow = track({ from: 0, to: 1, duration: 0.75, delay: 0.1, stagger: 0.18, ease: 'outCubic' })

const canvas = await Root({
  width: 640,
  height: 320,
  duration: grow.totalDuration(3), // long enough for all three staggered items
  fps: 24,
  children: page => Box({ children: SERIES.map((s, i) => Bar({ fill: grow.at(page, i) })) }),
})

| Option | Type | Description | | ---------- | --------------------------- | ---------------------------------------------------------------- | | from | number \| string \| array | Value before the track starts. Strings are colours. | | to | number \| string \| array | Value once it has finished. | | duration | number | Seconds the motion lasts. Required unless spring supplies one. | | delay | number | Seconds to wait before starting. | | stagger | number | Extra delay per item index, for offsetting a row of elements. | | ease | EasingName \| function | Easing curve. Mutually exclusive with spring. | | spring | SpringConfig | Spring physics instead of an easing; supplies its own duration. |

track.at(page, index?) reads the value, track.duration is when the first item finishes, and track.totalDuration(count) is when the last staggered one does.

Sequences

A track moves between two values. When a value has to move, wait, then move again, sequence chains the legs — each starting where the previous finished — and returns the same shape a track does, so the two are interchangeable at the call site.

import { sequence } from 'meo-canvas'

const badge = sequence({
  from: -40,
  steps: [
    { to: 0, spring: { stiffness: 200, damping: 14 } }, // drop in
    { to: 0, duration: 0.6, hold: 0.6 }, // rest there
    { to: -40, duration: 0.3, ease: 'inCubic' }, // leave
  ],
  delay: 0.2,
  stagger: 0.1,
})

badge.at(page) // or badge.at(page, index) when staggered

| Step option | Type | Description | | ----------- | --------------------------- | -------------------------------------------------------------- | | to | number \| string \| array | Value at the end of this leg. | | duration | number | Seconds this leg lasts. Required unless spring supplies one. | | ease | EasingName \| function | Easing for this leg. Mutually exclusive with spring. | | spring | SpringConfig | Spring physics for this leg; supplies its own duration. | | hold | number | Seconds to rest at to before the next leg begins. |

A trailing hold is not counted in duration, since nothing moves during it — a render sized from sequence.duration would otherwise end on dead frames.

Groups

parallel runs several of them at once: one sample per page, and one duration covering whichever member finishes last.

import { parallel, track } from 'meo-canvas'

const ring = parallel({
  tint: track({ from: '#38bdf8', to: '#f472b6', duration: 1.4, ease: 'inOutSine' }),
  scale: track({ from: 0.6, to: 1, spring: { stiffness: 190, damping: 12 } }),
})

const canvas = await Root({
  width: 200,
  height: 200,
  duration: ring.duration, // the longest member, whichever that is
  fps: 24,
  children: page => {
    const { tint, scale } = ring.at(page)
    return Box({ borderColor: tint, transform: { scale } })
  },
})

Groups take tracks, sequences and other groups, since all three are sampled the same way. The duration is the point: writing Math.max(a.duration, b.duration, …) by hand has to be corrected every time a track is added, and forgetting one leaves the render ending before its own animation does — silently, mid-fade.

Easing

easings carries the standard catalogue — linear, plus in/out/inOut of Quad, Cubic, Quart, Quint, Sine, Expo, Circ, Back, Elastic and Bounce. Every curve is pinned to 0 at the start and 1 at the end, and clamps outside that range. cubicBezier(x1, y1, x2, y2) builds a CSS-compatible curve, and steps(n) quantises.

Anywhere an easing is accepted, it can be a name or a function — resolveEasing(easing) is what turns one into the other, and it is exported for building your own utilities on the same footing. An absent easing resolves to linear.

Springs

Springs are solved in closed form, not simulated, so any page can be evaluated on its own:

import { spring, springDuration, track } from 'meo-canvas';

const config = { stiffness: 190, damping: 12 };

// A spring settles asymptotically, so let the physics size the render.
const canvas = await Root({ duration: springDuration(config), fps: 30, children: page => ... });

const scale = track({ from: 0.6, to: 1, spring: config });

| Option | Default | Description | | ----------- | ------- | --------------------------------------------------------------------- | | stiffness | 170 | How hard it pulls toward the target. | | damping | 26 | Resistance. Past critical it stops overshooting — and settles slower. | | mass | 1 | Inertia. | | velocity | 0 | Speed at t = 0, in units per second. |

Interpolation and colour

lerp(0, 100, 0.25) // 25 — unclamped, so overshooting easings still overshoot
mapRange(50, [0, 100], [0, 1], { clamp: true }) // 0.5
interpolate(0.25, [0, 0.5, 1], [0, 100, 0]) // 50 — keyframes, holding at both ends
mix('#000000', '#ffffff', 0.5) // '#808080'

mix blends numbers, arrays and colours. Colour parsing is delegated to the rendering engine rather than reimplemented, so every format the engine accepts works — named colours, #rgb/#rgba/ #rrggbb/#rrggbbaa, rgb()/rgba() in both legacy and modern syntax, hsl(), hwb(), lab(), lch(), oklab(), oklch() and color(display-p3 …). Anything the engine learns later works too. An unrecognised colour throws rather than rendering as a silent black.

Colours outside sRGB survive rather than being clipped. color(display-p3 1 0 0) is a redder red than sRGB can express, and it is carried as extended sRGB — channels above 255 or below 0 that name the same colour in sRGB's coordinates — so blending two wide-gamut colours does not quietly collapse them into duller ones. formatColor writes an ordinary colour as hex, or rgba() once alpha is involved, and switches to color(srgb …) only when a channel falls outside what hex can hold.

parseColor('color(display-p3 1 0 0)') // { r: 278.73, g: -57.81, b: -38.28, a: 1 }
mix('color(display-p3 1 0 0)', 'color(display-p3 0 1 0)', 0.5) // 'color(srgb 0.290625 0.395799 -0.230419)'
mix('#000000', '#ffffff', 0.5) // '#808080' — in gamut, so still hex

Alpha is a separate matter: the engine serialises it as one of 256 levels, so rgba(9, 9, 9, 0.12345) resolves to 0.122. That is the renderer's precision, not something this layer adds or removes.

Two more are exported for when you want them directly. mixColor(from, to, t) is what mix calls for colours, usable on its own when you know both ends are colours. isColor(css) answers whether the engine recognises a string, and never throws — the way to check before handing user input to a prop that would otherwise reject it.

mixColor('#000000', '#ffffff', 0.5) // '#808080'
isColor('rebeccapurple') // true
isColor('not a colour') // false

fps on Root sizes the sequence and derives time; it does not reach the encoder. Pass it again to toBuffer if the encoded animation should play at that rate, or give frameDelays one entry per page for uneven timing. GIF stores hundredths of a second, so 24fps alternates 40ms and 50ms frames; APNG stores a fraction and hits the rate exactly.

loop controls how many times it plays: 0 — the default — repeats forever, 1 plays it once, and any other number plays it that many times.

await canvas.toBuffer('gif', { fps: 24, loop: 0 }) // forever
await canvas.toBuffer('gif', { fps: 24, loop: 1 }) // once
await canvas.toBuffer('apng', { fps: 24, loop: 3 }) // three times

The two formats disagree about how to say this, and the encoder reconciles it: GIF counts the repeats that follow the first play, so three plays is stored as 2, and because 0 there already means "forever" a single play can only be expressed by leaving the block out entirely. APNG stores the play count directly.

Exporting part of a sequence

pageRange takes a span of pages instead of all of them — numbered from 1, inclusive at both ends, with negative numbers counting from the end.

await canvas.toBuffer('webp', { fps: 30, pageRange: [1, 20] }) // the first twenty pages
await canvas.toBuffer('webp', { fps: 30, pageRange: [21, -1] }) // everything from the twenty-first on
await canvas.toBuffer('pdf', { pageRange: [12, 18] }) // one chapter of a long document

The case it exists for is an intro that plays once followed by a loop that repeats forever. A single file cannot say that — it carries one loop count — so it is two exports of the same canvas:

const canvas = await Root({ width: 600, height: 300, pages: 60, fps: 30, children: page => card(page) })

const intro = await canvas.toBuffer('webp', { fps: 30, pageRange: [1, 20], loop: 1 })
const loop = await canvas.toBuffer('webp', { fps: 30, pageRange: [21, 60], loop: 0 })

Worth knowing before you build around it: whatever must survive looping has to be at its final value on the loop segment's first page. No animated format has a loop-start marker, so a repeat restarts at that frame — anything still mid-transition there flickers on every pass.

A bound the canvas does not have is a RangeError rather than a clamped range. The renderer validates before it encodes, so a non-worker canvas throws on the call itself while worker mode delivers the same error as a rejection — await inside a try catches both.

Box, Row, and Column

These are the fundamental layout components. Row and Column are wrappers around Box with a pre-set flexDirection. They all share the same props.

Layout Props

| Prop | Type | Description | | ----------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | width, height | number \| string | Sets the size of the node in pixels or percentage. | | minWidth, minHeight | number \| string | Sets the minimum size of the node. | | maxWidth, maxHeight | number \| string | Sets the maximum size of the node. | | flexDirection | Style.FlexDirection | Defines the direction of the main axis (Row, Column, etc.). | | justifyContent | Style.Justify | Defines how items are distributed along the main axis. | | alignItems | Style.Align | Defines how items are aligned along the cross axis. | | alignSelf | Style.Align | Overrides the parent'salignItems for a specific item. | | alignContent | Style.Align | Defines how lines are distributed when content wraps. | | flexGrow | number | Defines the ability of an item to grow. | | flexShrink | number | Defines the ability of an item to shrink. | | flexBasis | number \| 'auto' \| string | Defines the default size of an item along the main axis. | | flexWrap | Style.Wrap | Controls whether flex items wrap to multiple lines. | | positionType | Style.PositionType | CSS positionStatic, Relative, Absolute, Fixed or Sticky. See Positioning and stacking. | | position | object \| number \| string | Sets the offset for a positioned element. | | margin | object \| number \| string | Sets the margin space on the outside of the node. | | padding | object \| number \| string | Sets the padding space on the inside of the node. | | border | object \| number | Sets the width of the node's border. | | aspectRatio | number | Locks the aspect ratio (width / height) of the node. | | overflow | Style.Overflow | Defines how content that overflows is handled (Visible, Hidden). | | display | Style.Display | Controls if the node is included in layout (Flex, None). | | direction | Style.Direction | Sets the primary layout direction (LTR, RTL). | | gap | object \| number \| string | Defines the space between flex items. | | boxSizing | Style.BoxSizing | Howwidth and height are interpreted. Defaults to BorderBox, where CSS defaults to content-box. | | zIndex | number | Stack order among siblings, positioned or not. Naming any value creates a stacking context. | | children | CanvasElement \| CanvasElement[] | Child nodes to render inside this node. |

Styling Props

| Prop | Type | Description | | ----------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | backgroundColor | string | Sets the background color of the node. | | backgroundImage | { src, repeat?, size?, position? } | A picture behind the node's content, tiled as CSS background-repeat does — space and round included. | | borderColor | string \| EdgeColors | Colour of the node's border — one string for all four edges, or a colour per edge. Unset edges fall back to black. | | borderStyle | Style.Border | Sets the style of the border (Solid, Dashed, Dotted). | | borderRadius | CornerRadii \| number | Radius of the node's corners — one number for all four, or a radius per corner. | | opacity | number | Sets the opacity of the node and its children (0-1). | | filter | string | CSS filter chain applied to the node and its children as one picture — blur(4px) grayscale(1). | | backdropFilter | string | CSS backdrop-filter — filters what is behind the node, clipped to its box; the node's own background paints over it. | | mixBlendMode | Style.BlendMode | How the node and its children combine with what is painted behind — Multiply, Screen, Overlay and the rest of CSS's sixteen. | | gradient | object | A linear, radial or conic gradient as the background. A conic sweep starts at twelve o'clock, with from and at. | | dither | boolean | Breaks up gradient banding — see Smoothing gradients. Inherited by descendants. | | mask | Mask | Limits what of the node is drawn — see below. | | boxShadow | BoxShadowProps \| BoxShadowProps[] | Applies one or more box-shadow effects. | | transform | TransformProps | 2D transform, composed as the CSS list scale() rotate() translate() — see Transforms. |

Shadows

boxShadow takes one shadow or an array of them, drawn in the order given. The fields are the CSS box-shadow lengths under their own names:

Box({ boxShadow: { offsetX: 0, offsetY: 4, blur: 12, color: 'rgba(0,0,0,0.2)' } })

// A ring, which is what spread is for
Box({ boxShadow: { offsetX: 0, offsetY: 0, blur: 0, spread: 3, color: '#2563eb' } })

// Inset: the shadow falls inward from the edges the offset comes from
Box({ boxShadow: { inset: true, offsetX: 0, offsetY: 2, blur: 6, color: 'rgba(0,0,0,0.35)' } })

spread grows the shape before it is blurred, so a spread shadow is a larger copy rather than a wider blur; a square corner stays square however far it spreads, as the spec requires. blur is the CSS radius, not a standard deviation — the shadow is at half strength on the silhouette's edge and fades out over roughly that distance beyond it.

An outer shadow is never painted underneath its own box, which only shows when the background lets something through: a node with no background, or a translucent one, does not darken itself.

Masking

mask limits what of a node reaches the canvas — its background, border, content and children alike, the way CSS mask does. Every component takes it, Text, Image, Chart and Grid included.

// A shape inscribed in the node's box
Image({ src: avatar, width: 96, height: 96, mask: { shape: 'circle' } })

// SVG path data, in the node's own coordinates — 0,0 is its top-left corner
Box({ width: 100, height: 100, mask: 'M 50 0 L 100 100 L 0 100 Z' })

// A hole, via the even-odd rule
Box({ mask: { path: 'M 0 0 H 200 V 80 H 0 Z M 20 20 H 80 V 60 H 20 Z', fillRule: 'evenodd' } })

// A soft fade: only the alpha of each colour matters
Box({ mask: { gradient: { type: 'linear', direction: 'to-bottom', colors: ['#000', 'transparent'] } } })

| Form | Meaning | | -------------------- | ------------------------------------------------------------------------------------ | | 'M 0 0 …' | SVG path data. Shorthand for { path }. | | { shape } | 'circle' (sized by the shorter side) or 'ellipse' (fills the box). | | { path, fillRule } | 'nonzero' by default; 'evenodd' makes nested subpaths cut holes. | | { gradient } | The same shape as the gradient prop. Opaque keeps a pixel, transparent removes it. |

The two kinds cost differently. A shape or path clips — a yes-or-no test per pixel, cheap enough to put on every node in a list. A gradient composites: the node is drawn into an offscreen canvas of its own box and multiplied by the gradient's alpha, which is what buys the values in between. Reach for a shape unless you want a soft edge.

Two limits worth knowing before you design around them:

  • The mask applies to the node's layout box, before its own transform. Content a transform pushes outside that box is not masked back in.
  • A gradient that cannot be built — no colours, an unknown direction — drops the mask, not the node, and warns. Losing what was drawn would be a worse answer than losing how it was cut.

Positioning and stacking

positionType is CSS position, and the containing block rules are CSS's rather than Yoga's.

| Value | Where it is laid out | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Static | In the flow, painting in declaration order. This is what leaving the prop unset means. | | Relative | In the flow, then offset from where the flow put it. The space it would have taken stays reserved. | | Absolute | Out of the flow, offset from the nearest positioned ancestor. Static boxes in between are skipped, as CSS skips them. | | Fixed | Offset from the page, whatever it is nested in — unless an ancestor carries a transform, a filter or a backdropFilter, which captures it exactly as those capture it in CSS. | | Sticky | In the flow like a relative node, the