ResembleJS: Image Comparison and Visual Diff in JavaScript
ResembleJS is an open-source JavaScript library for image analysis and comparison. It compares two images pixel by pixel, returns a similarity percentage, and generates a visual diff highlighting the differences.
Unlike Percy or Applitools, ResembleJS is a library you integrate directly into your tests — no external service, no API keys, no per-screenshot pricing. Everything runs locally.
How ResembleJS Works
ResembleJS loads two images, compares each pixel, and produces:
- A mismatch percentage (0% = identical, 100% = completely different)
- A diff image with mismatched pixels highlighted in a configurable color
- Analysis of which areas differ
It handles anti-aliasing compensation, ignoring minor rendering differences that don't represent real visual changes.
Installation
npm install resemblejsFor Node.js (no browser canvas), install with the canvas peer dependency:
npm install resemblejs canvasBasic Usage
const { compare } = require('resemblejs')
compare('baseline.png', 'current.png', (err, data) => {
if (err) throw err
console.log(data.misMatchPercentage) // e.g., "0.31"
console.log(data.isSameDimensions) // true/false
console.log(data.dimensionDifference) // { width: 0, height: 0 }
// Save the diff image
require('fs').writeFileSync('diff.png', data.getBuffer())
})Or with promises:
const { compareImages } = require('resemblejs')
const fs = require('fs')
async function compareScreenshots(baseline, current) {
const data = await compareImages(
fs.readFileSync(baseline),
fs.readFileSync(current),
{
output: {
errorColor: { red: 255, green: 0, blue: 255 },
errorType: 'movement',
transparency: 0.3,
largeImageThreshold: 1200
},
scaleToSameSize: true,
ignore: 'antialiasing'
}
)
return {
mismatch: parseFloat(data.misMatchPercentage),
diff: data.getBuffer()
}
}Configuration Options
Ignore Modes
const options = {
ignore: 'antialiasing' // options: 'nothing', 'less', 'antialiasing', 'colors', 'alpha'
}nothing— compare every pixel exactlyless— ignore minor differences (sub-pixel rendering)antialiasing— ignore anti-aliased pixels (recommended for web screenshots)colors— compare structure only, ignore color differencesalpha— ignore transparency differences
Output Customization
const options = {
output: {
errorColor: { red: 255, green: 0, blue: 255 }, // magenta highlights
errorType: 'movement', // or 'flat', 'flatDifferenceIntensity'
transparency: 0.3, // overlay transparency
largeImageThreshold: 1200, // skip pixel-by-pixel above this width
useCrossOrigin: false
}
}Scale to Same Size
When comparing screenshots that might differ in dimensions:
const options = {
scaleToSameSize: true // scale current to match baseline dimensions
}Integration with Playwright
const { chromium } = require('playwright')
const { compareImages } = require('resemblejs')
const fs = require('fs').promises
const path = require('path')
const SNAPSHOTS_DIR = path.join(__dirname, 'snapshots')
const DIFFS_DIR = path.join(__dirname, 'diffs')
const THRESHOLD = 0.5 // 0.5% mismatch allowed
async function visualTest(testName, url, selector = null) {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
await page.goto(url)
await page.waitForLoadState('networkidle')
const screenshotOptions = selector
? { element: page.locator(selector) }
: { fullPage: true }
const currentBuffer = await page.screenshot(screenshotOptions)
await browser.close()
const baselinePath = path.join(SNAPSHOTS_DIR, `${testName}.png`)
const diffPath = path.join(DIFFS_DIR, `${testName}-diff.png`)
// Create baseline if it doesn't exist
try {
await fs.access(baselinePath)
} catch {
await fs.mkdir(SNAPSHOTS_DIR, { recursive: true })
await fs.writeFile(baselinePath, currentBuffer)
console.log(`Baseline created: ${baselinePath}`)
return { passed: true, mismatch: 0, isNewBaseline: true }
}
const baselineBuffer = await fs.readFile(baselinePath)
const comparison = await compareImages(baselineBuffer, currentBuffer, {
output: {
errorColor: { red: 255, green: 0, blue: 255 },
errorType: 'movement',
transparency: 0.3
},
ignore: 'antialiasing'
})
const mismatch = parseFloat(comparison.misMatchPercentage)
if (mismatch > THRESHOLD) {
await fs.mkdir(DIFFS_DIR, { recursive: true })
await fs.writeFile(diffPath, comparison.getBuffer())
console.error(`Visual regression: ${mismatch}% mismatch. Diff saved to ${diffPath}`)
}
return {
passed: mismatch <= THRESHOLD,
mismatch,
diffPath: mismatch > THRESHOLD ? diffPath : null
}
}
// Usage in tests
async function runTests() {
const results = []
results.push(await visualTest('homepage', 'http://localhost:3000'))
results.push(await visualTest('pricing', 'http://localhost:3000/pricing'))
results.push(await visualTest('hero-section', 'http://localhost:3000', '[data-testid="hero"]'))
const failures = results.filter(r => !r.passed)
if (failures.length > 0) {
console.error(`${failures.length} visual regressions detected`)
process.exit(1)
}
console.log('All visual tests passed')
}
runTests()Integration with Jest
// jest-setup.js
const { compareImages } = require('resemblejs')
const fs = require('fs')
const path = require('path')
expect.extend({
async toMatchVisualSnapshot(receivedBuffer, snapshotName, threshold = 0.5) {
const snapshotPath = path.join(__dirname, '__visual_snapshots__', `${snapshotName}.png`)
if (!fs.existsSync(snapshotPath)) {
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true })
fs.writeFileSync(snapshotPath, receivedBuffer)
return { pass: true, message: () => `New snapshot created: ${snapshotName}` }
}
const baseline = fs.readFileSync(snapshotPath)
const comparison = await compareImages(baseline, receivedBuffer, {
ignore: 'antialiasing'
})
const mismatch = parseFloat(comparison.misMatchPercentage)
const pass = mismatch <= threshold
if (!pass) {
const diffPath = path.join(__dirname, '__visual_diffs__', `${snapshotName}.png`)
fs.mkdirSync(path.dirname(diffPath), { recursive: true })
fs.writeFileSync(diffPath, comparison.getBuffer())
}
return {
pass,
message: () => pass
? `Expected ${snapshotName} not to match baseline`
: `Visual mismatch: ${mismatch}% exceeds ${threshold}% threshold for ${snapshotName}`
}
}
})// homepage.visual.test.js
const { chromium } = require('playwright')
describe('Homepage visual regression', () => {
let browser, page
beforeAll(async () => {
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
})
afterAll(async () => {
await browser.close()
})
test('homepage matches baseline', async () => {
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
const screenshot = await page.screenshot({ fullPage: true })
await expect(screenshot).toMatchVisualSnapshot('homepage', 0.5)
})
})Integration with Puppeteer
const puppeteer = require('puppeteer')
const { compareImages } = require('resemblejs')
const fs = require('fs').promises
async function puppeteerVisualTest(url, snapshotName) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
await page.setViewport({ width: 1280, height: 800 })
await page.goto(url, { waitUntil: 'networkidle0' })
const screenshot = await page.screenshot({ fullPage: true })
await browser.close()
const baselinePath = `snapshots/${snapshotName}.png`
try {
const baseline = await fs.readFile(baselinePath)
const result = await compareImages(baseline, screenshot, {
ignore: 'antialiasing',
output: { errorType: 'movement', transparency: 0.3 }
})
return {
mismatch: parseFloat(result.misMatchPercentage),
diff: result.getBuffer()
}
} catch {
await fs.mkdir('snapshots', { recursive: true })
await fs.writeFile(baselinePath, screenshot)
return { mismatch: 0, isNew: true }
}
}Comparing Specific Regions
For large pages where only specific sections matter:
const Jimp = require('jimp')
async function compareRegion(baseline, current, region) {
const { x, y, width, height } = region
const baselineImg = await Jimp.read(baseline)
const currentImg = await Jimp.read(current)
const baselineCrop = baselineImg.crop(x, y, width, height).getBufferAsync(Jimp.MIME_PNG)
const currentCrop = currentImg.crop(x, y, width, height).getBufferAsync(Jimp.MIME_PNG)
return compareImages(await baselineCrop, await currentCrop, { ignore: 'antialiasing' })
}
// Compare only the navigation bar
const result = await compareRegion('baseline.png', 'current.png', {
x: 0, y: 0, width: 1280, height: 80
})Limitations
ResembleJS is a local library — it has no:
- Baseline management UI or review workflow
- Cross-browser rendering (you get whatever your local Chrome renders)
- Team collaboration features
- PR integration
It's the right tool when you want full control, zero external dependencies, and don't need cloud features. For teams that need review workflows, PR gates, or cross-browser coverage, Percy or Applitools are better fits.
When to Use ResembleJS
Good fit:
- Internal tooling where you control the test environment
- CI environments where Docker ensures consistent rendering
- Teams that want to own the entire visual testing stack
- Projects where external services aren't an option (air-gapped environments)
Better served by Percy/Applitools:
- Products with cross-browser requirements
- Teams that need designers to review visual changes
- Projects where false positives from rendering differences are a constant problem
ResembleJS gives you the comparison primitive. The baseline management, workflow, and tooling around it — you build yourself. That's a significant investment for teams with other priorities, but valuable when you need the control.