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
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);
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 optionaloffsetX/offsetYnudges. Or setleft/topfor pixel-precise placement — those override gravity, and off-canvas/negative values are clipped for you. -
opacity(0.0–1.0) fades a layer;resizescales it before compositing;tile: truerepeats it across the whole base. -
blendpicks the blend mode —over(default),multiply,screen,overlay,darken,lighten, oradd, 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 }],
});
Blend a gradient or texture over a photo:
const blended = await composite(photo, {
layers: [{ input: gradient, blend: 'multiply', opacity: 0.6 }],
});
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 } },
});
A couple of things worth knowing
- The base is decoded to RGBA, so the result preserves transparency by default —
composite()returns PNG unless you setoutput. 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 anAbortSignalor atimeoutMsviaAsyncOptions. There's acompositeSync()if you'd rather stay synchronous. - Fully transparent source pixels and fully opaque
overpaints 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)