Learn to build a complete Image Resizer and Compressor using JavaScript Canvas API. Resize dimensions, control quality, preview results, and download compressed images — all client-side.
Introduction
Images are the heaviest assets on most websites. A single unoptimized photo can be 3–5 MB, slowing page loads dramatically. In this project, you'll build a client-side Image Resizer & Compressor using pure JavaScript and the HTML5 Canvas API.
What you'll learn:
- Reading image files with
FileReader - Drawing and resizing images on a
<canvas> element - Controlling output quality with
canvas.toBlob() and canvas.toDataURL() - Maintaining aspect ratio during resize
- Generating downloadable compressed images — all without any server
This is a practical, real-world tool that many developers build for portfolio projects and production apps. By the end, you'll have a fully working image optimizer running entirely in the browser.
HTML Structure
We need a file input, dimension controls, quality slider, preview area, and download button.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Resizer & Compressor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
h1 { color: #00d4ff; margin-bottom: 1.5rem; }
.container {
background: #16213e;
padding: 2rem;
border-radius: 12px;
width: 100%;
max-width: 600px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.controls { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; }
.controls label { display: flex; flex-direction: column; gap: 4px; font-size: 0.9rem; }
.controls input[type="number"],
.controls select {
padding: 8px; border-radius: 6px; border: 1px solid #333;
background: #0f3460; color: #fff; width: 120px;
}
.controls input[type="range"] { width: 200px; }
#file-input {
padding: 12px; border: 2px dashed #00d4ff; border-radius: 8px;
width: 100%; cursor: pointer; background: transparent; color: #ccc;
}
.preview-area {
margin-top: 1.5rem; text-align: center;
}
.preview-area img {
max-width: 100%; border-radius: 8px;
border: 1px solid #333; margin-top: 0.5rem;
}
.info { font-size: 0.85rem; color: #aaa; margin-top: 0.5rem; }
#download-btn {
margin-top: 1rem; padding: 12px 24px;
background: #00d4ff; color: #000; border: none;
border-radius: 8px; font-weight: bold; cursor: pointer;
font-size: 1rem; display: none;
}
#download-btn:hover { background: #00b8e6; }
canvas { display: none; }
</style>
</head>
<body>
<h1>🖼️ Image Resizer & Compressor</h1>
<div class="container">
<input type="file" id="file-input" accept="image/*" />
<div class="controls">
<label>Width (px)
<input type="number" id="width-input" placeholder="Auto" />
</label>
<label>Height (px)
<input type="number" id="height-input" placeholder="Auto" />
</label>
<label>Format
<select id="format-select">
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WebP</option>
</select>
</label>
<label>Quality: <span id="quality-value">80%</span>
<input type="range" id="quality-slider" min="10" max="100" value="80" />
</label>
<label>
<input type="checkbox" id="aspect-lock" checked /> Lock Aspect Ratio
</label>
</div>
<canvas id="canvas"></canvas>
<div class="preview-area">
<p class="info" id="original-info"></p>
<img id="preview" alt="Preview will appear here" />
<p class="info" id="output-info"></p>
</div>
<button id="download-btn">⬇ Download Compressed Image</button>
</div>
<script src="app.js"></script>
</body>
</html>
A dark-themed UI appears with:
- File input with dashed border
- Width/Height number inputs
- Format dropdown (JPEG/PNG/WebP)
- Quality slider (10%–100%)
- Lock Aspect Ratio checkbox
- Preview area and Download button (hidden until image is processed)
JavaScript — Step by Step
Step 1: Reading the File
When the user selects an image, we read it using FileReader and load it into an Image object.
// app.js — Step 1: File Reading
const fileInput = document.getElementById('file-input');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const preview = document.getElementById('preview');
const downloadBtn = document.getElementById('download-btn');
const widthInput = document.getElementById('width-input');
const heightInput = document.getElementById('height-input');
const qualitySlider = document.getElementById('quality-slider');
const qualityValue = document.getElementById('quality-value');
const formatSelect = document.getElementById('format-select');
const aspectLock = document.getElementById('aspect-lock');
const originalInfo = document.getElementById('original-info');
const outputInfo = document.getElementById('output-info');
let originalImage = null;
let originalFileSize = 0;
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select a valid image file.');
return;
}
originalFileSize = file.size;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
originalImage = img;
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
originalInfo.textContent =
`Original: ${img.naturalWidth} × ${img.naturalHeight} | ${formatBytes(file.size)}`;
processImage();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
});
When user selects "photo.jpg" (2400×1600, 2.3 MB):
→ Original: 2400 × 1600 | 2.3 MB
→ Width input shows: 2400
→ Height input shows: 1600
Step 2: Maintaining Aspect Ratio
When the user changes width or height, we recalculate the other dimension to keep proportions intact.
// Step 2: Aspect Ratio Logic
let aspectRatio = 1;
widthInput.addEventListener('input', () => {
if (!originalImage) return;
aspectRatio = originalImage.naturalWidth / originalImage.naturalHeight;
if (aspectLock.checked) {
const newWidth = parseInt(widthInput.value) || 0;
heightInput.value = Math.round(newWidth / aspectRatio);
}
processImage();
});
heightInput.addEventListener('input', () => {
if (!originalImage) return;
aspectRatio = originalImage.naturalWidth / originalImage.naturalHeight;
if (aspectLock.checked) {
const newHeight = parseInt(heightInput.value) || 0;
widthInput.value = Math.round(newHeight * aspectRatio);
}
processImage();
});
User types 1200 in Width (aspect ratio locked):
→ Height auto-updates to 800 (maintains 3:2 ratio)
→ Image reprocesses at 1200×800
Step 3: Drawing to Canvas & Resizing
This is the core logic — we resize the canvas and draw the image at new dimensions.
// Step 3: Resize and Compress
function processImage() {
if (!originalImage) return;
const targetWidth = parseInt(widthInput.value) || originalImage.naturalWidth;
const targetHeight = parseInt(heightInput.value) || originalImage.naturalHeight;
const quality = parseInt(qualitySlider.value) / 100;
const format = formatSelect.value;
// Resize canvas to target dimensions
canvas.width = targetWidth;
canvas.height = targetHeight;
// Clear and draw image at new size
ctx.clearRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(originalImage, 0, 0, targetWidth, targetHeight);
// Generate compressed output using toBlob
canvas.toBlob(
(blob) => {
if (!blob) return;
// Create preview URL
const url = URL.createObjectURL(blob);
preview.src = url;
// Show output info
const reduction = ((1 - blob.size / originalFileSize) * 100).toFixed(1);
outputInfo.textContent =
`Output: ${targetWidth} × ${targetHeight} | ${formatBytes(blob.size)} | ${reduction}% smaller`;
// Setup download
downloadBtn.style.display = 'inline-block';
downloadBtn.onclick = () => {
const a = document.createElement('a');
a.href = url;
const ext = format.split('/')[1];
a.download = `resized-image.${ext}`;
a.click();
};
},
format,
quality
);
}
Original: 2400×1600, 2.3 MB (JPEG)
Settings: Width=1200, Height=800, Quality=70%, Format=JPEG
→ Output: 1200 × 800 | 187 KB | 92.1% smaller
→ Download button appears
// Step 4: Event listeners for quality and format
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = `${qualitySlider.value}%`;
processImage();
});
formatSelect.addEventListener('change', () => {
processImage();
});
aspectLock.addEventListener('change', () => {
if (aspectLock.checked && originalImage) {
// Reset to correct aspect ratio based on current width
const currentWidth = parseInt(widthInput.value) || originalImage.naturalWidth;
aspectRatio = originalImage.naturalWidth / originalImage.naturalHeight;
heightInput.value = Math.round(currentWidth / aspectRatio);
processImage();
}
});
User drags quality slider to 50%:
→ Label updates: "Quality: 50%"
→ Output: 1200 × 800 | 98 KB | 95.7% smaller
User switches format to WebP:
→ Output: 1200 × 800 | 72 KB | 96.9% smaller
Step 5: Utility Function
// Step 5: Helper to format file sizes
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
formatBytes(2415919) → "2.3 MB"
formatBytes(187340) → "182.95 KB"
formatBytes(0) → "0 Bytes"
Complete Code (Single File)
Here's everything combined in a single HTML file you can open directly in a browser:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Resizer & Compressor — JS Canvas API</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #1a1a2e; color: #eee;
min-height: 100vh; display: flex;
flex-direction: column; align-items: center; padding: 2rem;
}
h1 { color: #00d4ff; margin-bottom: 1.5rem; }
.container {
background: #16213e; padding: 2rem; border-radius: 12px;
width: 100%; max-width: 600px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.controls { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; }
.controls label {
display: flex; flex-direction: column; gap: 4px; font-size: 0.9rem;
}
.controls input[type="number"], .controls select {
padding: 8px; border-radius: 6px; border: 1px solid #333;
background: #0f3460; color: #fff; width: 120px;
}
.controls input[type="range"] { width: 200px; }
#file-input {
padding: 12px; border: 2px dashed #00d4ff; border-radius: 8px;
width: 100%; cursor: pointer; background: transparent; color: #ccc;
}
.preview-area { margin-top: 1.5rem; text-align: center; }
.preview-area img {
max-width: 100%; border-radius: 8px;
border: 1px solid #333; margin-top: 0.5rem;
}
.info { font-size: 0.85rem; color: #aaa; margin-top: 0.5rem; }
#download-btn {
margin-top: 1rem; padding: 12px 24px;
background: #00d4ff; color: #000; border: none;
border-radius: 8px; font-weight: bold; cursor: pointer;
font-size: 1rem; display: none;
}
#download-btn:hover { background: #00b8e6; }
canvas { display: none; }
</style>
</head>
<body>
<h1>🖼️ Image Resizer & Compressor</h1>
<div class="container">
<input type="file" id="file-input" accept="image/*" />
<div class="controls">
<label>Width (px)
<input type="number" id="width-input" placeholder="Auto" />
</label>
<label>Height (px)
<input type="number" id="height-input" placeholder="Auto" />
</label>
<label>Format
<select id="format-select">
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WebP</option>
</select>
</label>
<label>Quality: <span id="quality-value">80%</span>
<input type="range" id="quality-slider" min="10" max="100" value="80" />
</label>
<label>
<input type="checkbox" id="aspect-lock" checked /> Lock Aspect Ratio
</label>
</div>
<canvas id="canvas"></canvas>
<div class="preview-area">
<p class="info" id="original-info"></p>
<img id="preview" alt="Preview" />
<p class="info" id="output-info"></p>
</div>
<button id="download-btn">⬇ Download Compressed Image</button>
</div>
<script>
const fileInput = document.getElementById('file-input');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const preview = document.getElementById('preview');
const downloadBtn = document.getElementById('download-btn');
const widthInput = document.getElementById('width-input');
const heightInput = document.getElementById('height-input');
const qualitySlider = document.getElementById('quality-slider');
const qualityValue = document.getElementById('quality-value');
const formatSelect = document.getElementById('format-select');
const aspectLock = document.getElementById('aspect-lock');
const originalInfo = document.getElementById('original-info');
const outputInfo = document.getElementById('output-info');
let originalImage = null;
let originalFileSize = 0;
// File Input Handler
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file || !file.type.startsWith('image/')) {
alert('Please select a valid image file.');
return;
}
originalFileSize = file.size;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
originalImage = img;
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
originalInfo.textContent =
`Original: ${img.naturalWidth} × ${img.naturalHeight} | ${formatBytes(file.size)}`;
processImage();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
});
// Aspect Ratio — Width changed
widthInput.addEventListener('input', () => {
if (!originalImage) return;
if (aspectLock.checked) {
const ratio = originalImage.naturalWidth / originalImage.naturalHeight;
heightInput.value = Math.round((parseInt(widthInput.value) || 0) / ratio);
}
processImage();
});
// Aspect Ratio — Height changed
heightInput.addEventListener('input', () => {
if (!originalImage) return;
if (aspectLock.checked) {
const ratio = originalImage.naturalWidth / originalImage.naturalHeight;
widthInput.value = Math.round((parseInt(heightInput.value) || 0) * ratio);
}
processImage();
});
// Quality & Format
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = `${qualitySlider.value}%`;
processImage();
});
formatSelect.addEventListener('change', processImage);
aspectLock.addEventListener('change', () => {
if (aspectLock.checked && originalImage) {
const ratio = originalImage.naturalWidth / originalImage.naturalHeight;
heightInput.value = Math.round((parseInt(widthInput.value) || 0) / ratio);
processImage();
}
});
// Core Processing Function
function processImage() {
if (!originalImage) return;
const targetWidth = parseInt(widthInput.value) || originalImage.naturalWidth;
const targetHeight = parseInt(heightInput.value) || originalImage.naturalHeight;
const quality = parseInt(qualitySlider.value) / 100;
const format = formatSelect.value;
canvas.width = targetWidth;
canvas.height = targetHeight;
ctx.clearRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(originalImage, 0, 0, targetWidth, targetHeight);
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
preview.src = url;
const reduction = ((1 - blob.size / originalFileSize) * 100).toFixed(1);
outputInfo.textContent =
`Output: ${targetWidth} × ${targetHeight} | ${formatBytes(blob.size)} | ${reduction}% smaller`;
downloadBtn.style.display = 'inline-block';
downloadBtn.onclick = () => {
const a = document.createElement('a');
a.href = url;
a.download = `resized-image.${format.split('/')[1]}`;
a.click();
};
}, format, quality);
}
// Utility
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
</body>
</html>
Full working application:
1. User selects "vacation-photo.jpg" (3000×2000, 4.1 MB)
2. Original info displays: "Original: 3000 × 2000 | 4.1 MB"
3. User sets Width to 1500 → Height auto-adjusts to 1000
4. Quality slider at 75%, Format: WebP
5. Preview shows resized image
6. Output info: "Output: 1500 × 1000 | 156 KB | 96.3% smaller"
7. Click Download → saves "resized-image.webp"
Key Takeaways
- Canvas is a pixel manipulator —
drawImage() with different target dimensions performs the actual resize. The browser's built-in interpolation handles smoothing.
toBlob() is better than toDataURL() for downloads — Blobs are memory-efficient binary objects. toDataURL() creates a base64 string that's ~33% larger in memory.
- Quality parameter only works for lossy formats — Setting quality on PNG has no effect because PNG is lossless. Use JPEG (0.6–0.8) or WebP (0.7–0.85) for real compression.
- Always validate file input — Check
file.type.startsWith('image/') before processing. Non-image files will cause Image() to fail silently.
- Aspect ratio math is simple division — Store
width/height as ratio. When one dimension changes, calculate the other: newHeight = newWidth / ratio.
URL.createObjectURL() needs cleanup — Each call allocates memory. In production, call URL.revokeObjectURL(oldUrl) before creating a new one to prevent memory leaks.
- WebP gives the best compression — For photographic images, WebP at 0.75 quality typically produces files 25–35% smaller than JPEG at the same visual quality.
- No server needed — The entire operation happens in the browser. FileReader, Canvas, and Blob APIs make client-side image processing fully viable for most use cases.
FAQ
Q1: Why does my PNG output sometimes become LARGER than the original?
PNG is a lossless format — it preserves every pixel exactly. When you resize a photographic image and export as PNG, the compressed pixel data can actually be larger than a well-optimized JPEG original. Solution: Use JPEG or WebP for photographs. PNG is best for graphics with flat colors, text, or transparency.
// Check if PNG is making file larger
canvas.toBlob((blob) => {
if (blob.size > originalFileSize) {
console.warn('PNG output is larger! Consider JPEG or WebP.');
// Auto-fallback to JPEG
canvas.toBlob((jpegBlob) => {
console.log('JPEG size:', formatBytes(jpegBlob.size));
}, 'image/jpeg', 0.8);
}
}, 'image/png');
Q2: How can I resize very large images (10,000+ pixels) without crashing the browser?
Extremely large images can exceed the browser's maximum canvas size (varies by browser, typically 16,384×16,384 pixels). For very large files, resize in steps:
// Step-down approach for very large images
function stepResize(img, targetWidth, targetHeight) {
let currentWidth = img.naturalWidth;
let currentHeight = img.naturalHeight;
let source = img;
// Halve dimensions until close to target
while (currentWidth / 2 > targetWidth) {
const stepCanvas = document.createElement('canvas');
currentWidth = Math.round(currentWidth / 2);
currentHeight = Math.round(currentHeight / 2);
stepCanvas.width = currentWidth;
stepCanvas.height = currentHeight;
stepCanvas.getContext('2d').drawImage(source, 0, 0, currentWidth, currentHeight);
source = stepCanvas;
}
// Final resize to exact target
canvas.width = targetWidth;
canvas.height = targetHeight;
ctx.drawImage(source, 0, 0, targetWidth, targetHeight);
}
Q3: Can I compress images without changing their dimensions?
Absolutely! Simply keep the original width and height and only adjust the quality slider and format. The Canvas will redraw at the same size, but toBlob() with a lower quality value (e.g., 0.6) will produce a significantly smaller file:
// Compress without resizing — keep original dimensions
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
ctx.drawImage(originalImage, 0, 0);
// Quality 0.6 = roughly 60-70% size reduction for JPEG
canvas.toBlob((blob) => {
console.log(`Compressed: ${formatBytes(blob.size)}`);
// Original 4.1 MB → Compressed ~800 KB (same dimensions!)
}, 'image/jpeg', 0.6);
This is exactly what happens in our tool when you don't change the width/height values — you get pure compression without any dimension change.
Summary
You've built a complete, production-ready Image Resizer & Compressor using only vanilla JavaScript and the Canvas API. This project demonstrates real-world skills: file handling, pixel manipulation, format conversion, and creating downloadable outputs — all running entirely in the browser with zero server dependencies.