DEV Community

Cover image for Watermarks, overlays, and blend modes in a few lines — imgkit now has composite()
Aissam Irhir
Aissam Irhir

Posted on

Watermarks, overlays, and blend modes in a few lines — imgkit now has composite()

If you ship anything with user-generated or product imagery, you eventually need to put one image on top of another: a logo in the corner, a "SAMPLE" stamp across a preview, a badge on a product shot, a gradient blended over a hero photo.

This usually means reaching for sharp and stitching together its composite API, or dropping down to node-canvas and doing the math by hand. The latest release of imgkit (v2.3.0) adds a dedicated composite() for exactly this — Rust-backed, async off the main thread, and the same on Node.js and Bun.

Install

bun add imgkit
# or
npm install imgkit
Enter fullscreen mode Exit fullscreen mode

Prebuilt binaries ship for the common platforms, so there's no build step on install.

The hello-world: a corner watermark

The most common case — a semi-transparent logo anchored to the bottom-right corner, nudged in from the edges:

import { composite, resize } from 'imgkit';

const photo = Buffer.from(await Bun.file('photo.jpg').arrayBuffer());
const logoRaw = Buffer.from(await Bun.file('logo.png').arrayBuffer());

// Scale the logo first, then composite
const logo = await resize(logoRaw, { width: 180 });

const watermarked = await composite(photo, {
  layers: [
    { input: logo, gravity: 'southEast', opacity: 0.7, offsetX: -32, offsetY: -32 },
  ],
  output: { format: 'jpeg', jpeg: { quality: 90 } },
});

await Bun.write('watermarked.jpg', watermarked);
Enter fullscreen mode Exit fullscreen mode

That's the whole API surface for the simple case. Everything else is just more layers and more options.

The mental model

composite() paints an array of layers onto a base image. The rules are small enough to keep in your head:

  • Layers paint in array order — the first layer is the bottom-most overlay, the last sits on top.
  • Placement is either gravity or absolute. Use gravity (center, north, southEast, etc.) to anchor a layer to a region, with optional offsetX / offsetY nudges. Or set left / top for pixel-precise placement — those override gravity, and off-canvas/negative values are clipped for you.
  • opacity (0.0–1.0) fades a layer; resize scales it before compositing; tile: true repeats it across the whole base.
  • blend picks the blend mode — over (default), multiply, screen, overlay, darken, lighten, or add, following the standard W3C separable blend formulas.

Recipes

Tiled "SAMPLE" / "DRAFT" stamp across the whole image:

const tiled = await composite(photo, {
  layers: [{ input: stamp, tile: true, opacity: 0.15 }],
});
Enter fullscreen mode Exit fullscreen mode

Blend a gradient or texture over a photo:

const blended = await composite(photo, {
  layers: [{ input: gradient, blend: 'multiply', opacity: 0.6 }],
});
Enter fullscreen mode Exit fullscreen mode

Stack several layers — a product on a background, a badge top-right, a faded logo bottom-left:

const out = await composite(background, {
  layers: [
    { input: product, gravity: 'center' },
    { input: badge,   gravity: 'northEast', resize: { width: 80 } },
    { input: logo,    gravity: 'southWest', opacity: 0.8 },
  ],
  output: { format: 'webp', webp: { quality: 90 } },
});
Enter fullscreen mode Exit fullscreen mode

A couple of things worth knowing

  • The base is decoded to RGBA, so the result preserves transparency by defaultcomposite() returns PNG unless you set output. Choosing a JPEG output flattens any alpha (it's dropped, not matted), which is usually what you want for a final watermarked photo.
  • For lots of layers or large canvases, the async composite() runs off the main thread and can be cancelled with an AbortSignal or a timeoutMs via AsyncOptions. There's a compositeSync() if you'd rather stay synchronous.
  • Fully transparent source pixels and fully opaque over paints take fast paths, so the common watermark case stays cheap.

Why I built it this way

imgkit's whole point is to keep the fast path in Rust (via napi-rs) while exposing an API that feels native to JS — one function, plain options objects, Buffers in and out, identical behavior on Node and Bun. composite() follows that: no canvas, no manual pixel loops, no separate paths for "watermark" vs "blend" vs "tile." They're all just layers.

If you want the full option reference and more recipes, the docs are here:

If you try it, I'd love to hear what you're compositing — and issues/stars on GitHub are always welcome.

Top comments (0)