Combine images in JavaScript

combine-images is a small, zero-dependency JavaScript package that combines images side by side, vertically or in a grid on a canvas. It runs in the browser, in Web Workers and in Node, and it is the layout code behind the PhotoMerge editor.

npm · GitHub · MIT license · v1.0.0

npm install combine-images

Try it here

This demo runs the package in your browser. Nothing is uploaded.

Choose images or use the samples.

Combine images in the browser

Pass files, Blobs, URLs or loaded images. The result is a canvas you can show, encode or upload.

import { combineImages, canvasToBlob } from 'combine-images';

// Files from <input type="file" multiple>, Blobs, URLs or loaded <img> elements
const canvas = await combineImages(input.files, {
  direction: 'horizontal', // 'horizontal' | 'vertical' | 'grid'
  gap: 16,
  padding: 16,
  background: '#ffffff',
  labels: ['Before', 'After'],
});

const blob = await canvasToBlob(canvas, 'image/jpeg', 0.92);

Without a bundler, import it straight from a CDN:

<script type="module">
  import { combineImages } from 'https://cdn.jsdelivr.net/npm/combine-images@1/src/index.js';
</script>

Combine images in Node

Node has no built-in canvas, so pass createCanvas and loadImage from @napi-rs/canvas (or the canvas package). Sources can then be file paths, URLs or Buffers.

import { writeFile } from 'node:fs/promises';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { combineImages } from 'combine-images';

const canvas = await combineImages(['a.jpg', 'b.jpg', 'c.jpg'], {
  direction: 'grid',
  gap: 12,
  createCanvas,
  loadImage,
});
await writeFile('grid.png', await canvas.encode('png'));

Layouts

directionWhat it doesSize options
horizontal (default)One row. Each image keeps its shape and is scaled to the same height.height, default the shortest image
verticalOne column. Each image keeps its shape and is scaled to the same width.width, default the narrowest image
gridEqual cells. A short last row is centred.columns, cellWidth, cellHeight, fit (cover or contain)

Scaling to a shared height or width avoids the usual problem with merging photos of different sizes: no stretched faces and no uneven strips of background. The grid crops to fill each cell by default; use fit: 'contain' to show whole images instead.

Options

OptionDefaultNotes
gap0Space between images, in output pixels
padding0Space around the result
background'#ffffff'Any canvas color. null keeps it transparent; export PNG or WebP
radius0Corner radius of each image
labels[]Text per image, by index, e.g. Before and After
labelPosition'bottom-left'top-left, top-center, bottom-left, bottom-center
labelColor, labelBackground, labelFont, labelSizewhite on 60% black, system font, 0.065labelSize is a share of the shorter cell side
maxSizenoneScale the result down so its longest side fits
scale1Multiply the output size
canvas, createCanvasOffscreenCanvas or a new canvas elementWhere to draw
loadImagecreateImageBitmap in browsersHow to load sources that are not images yet

Use only the layout math

computeLayout takes sizes and returns the output size and one rectangle per image. It never touches a canvas, so you can render with WebGL, SVG, sharp or a server-side pipeline, or show a preview before decoding full-size files.

import { computeLayout, drawLayout } from 'combine-images';

const layout = computeLayout(
  [{ width: 1200, height: 800 }, { width: 800, height: 1200 }],
  { gap: 10 }
);
// { width: 1743, height: 800,
//   cells: [{ index: 0, x: 0, y: 0, width: 1200, height: 800 },
//           { index: 1, x: 1210, y: 0, width: 533.33, height: 800 }] }

drawLayout(canvas, images, layout, { background: '#000', scale: 0.25 });

Combine images in a Web Worker

Large photos can block the page while they decode and draw. In a worker, combineImages uses OffscreenCanvas automatically.

// worker.js: combine off the main thread with OffscreenCanvas
import { combineImages } from 'combine-images';

self.onmessage = async ({ data: files }) => {
  const canvas = await combineImages(files, { direction: 'vertical', maxSize: 4000 });
  self.postMessage(await canvas.convertToBlob({ type: 'image/webp', quality: 0.9 }));
};

Canvas size limits

Browsers cap canvas size: Safari at about 16.7 million pixels, Chrome and Firefox at about 268 million, and 32,767 pixels on a side. Ten 12-megapixel photos in one row can pass those limits, so set maxSize when users choose many large images. JPEG encoding also has a 65,535 pixel limit per side.

No code needed?

To merge a few photos by hand, use the free PhotoMerge editor: side by side, vertically or as a grid, with no upload, signup or watermark.