Need to shrink an image before uploading it somewhere? This JavaScript image compressor works entirely inside the browser. The image isn’t sent to a server, there’s no account required and no external library doing the work.
This is part of Copy, Run & Customize, a series of small projects you can open, change and use in your own work.
I kept this one dependency-free on purpose. It lets you choose a JPEG, PNG or WebP image, change the quality and maximum width, then download the compressed result. It also shows how much space you actually saved, which is useful because sometimes “compressed” images somehow end up larger than the original.
Want to try or change it? Copy the three blocks below, then open the TeknoFenomen Code Playground to run the HTML, CSS and JavaScript in your browser.
HTML
<section class="compressor">
<header class="tool-header">
<span class="eyebrow">LOCAL IMAGE TOOL</span>
<h1>Browser Image Compressor</h1>
<p>
Compress JPEG, PNG and WebP images without uploading them
anywhere.
</p>
</header>
<label class="drop-zone" id="dropZone" for="fileInput">
<input
id="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
hidden
>
<span class="drop-icon">↓</span>
<strong>Drop an image here</strong>
<span>or tap to choose one</span>
<small>JPEG, PNG or WebP — maximum 30 MB</small>
</label>
<div class="workspace" id="workspace" hidden>
<div class="settings">
<div class="field">
<label for="quality">
Quality
<output id="qualityValue">75%</output>
</label>
<input
id="quality"
type="range"
min="10"
max="95"
value="75"
>
</div>
<div class="field">
<label for="maxWidth">Maximum width</label>
<select id="maxWidth">
<option value="0">Keep original width</option>
<option value="2560">2560 px</option>
<option value="1920" selected>1920 px</option>
<option value="1280">1280 px</option>
<option value="800">800 px</option>
</select>
</div>
<div class="field">
<label for="format">Output format</label>
<select id="format">
<option value="image/webp">WebP</option>
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
</select>
<small id="formatNote">
WebP usually gives the smallest result.
</small>
</div>
<button class="primary-button" id="compressButton">
Compress image
</button>
<p class="privacy-note">
Your image stays on this device.
</p>
</div>
<div class="preview-grid">
<article class="preview-card">
<div class="card-heading">
<strong>Original</strong>
<span id="originalSize">—</span>
</div>
<div class="image-frame">
<img id="originalPreview" alt="Original image preview">
</div>
<p id="originalDetails">No image selected</p>
</article>
<article class="preview-card">
<div class="card-heading">
<strong>Compressed</strong>
<span id="resultSize">—</span>
</div>
<div class="image-frame result-frame">
<p id="resultPlaceholder">
The result will appear here.
</p>
<img
id="resultPreview"
alt="Compressed image preview"
hidden
>
</div>
<p id="resultDetails">Waiting for compression</p>
</article>
</div>
<div class="actions">
<button id="downloadButton" disabled>
Download compressed image
</button>
<button class="secondary-button" id="resetButton">
Choose another image
</button>
</div>
<p class="status" id="status" role="status" aria-live="polite"></p>
</div>
</section>
CSS
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
padding: 24px;
display: grid;
place-items: center;
color: #e8ecf5;
background:
radial-gradient(circle at top left, #243056, transparent 38%),
#0d1018;
font-family:
Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
[hidden] {
display: none !important;
}
button,
select,
input {
font: inherit;
}
.compressor {
width: min(100%, 980px);
padding: clamp(20px, 4vw, 38px);
border: 1px solid #293044;
border-radius: 24px;
background: rgba(20, 24, 35, 0.96);
box-shadow: 0 25px 70px rgba(0, 0, 0, 0.38);
}
.tool-header {
max-width: 650px;
margin-bottom: 26px;
}
.eyebrow {
color: #8ca9ff;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.16em;
}
h1 {
margin: 8px 0 10px;
font-size: clamp(2rem, 6vw, 3.4rem);
line-height: 1;
}
.tool-header p {
margin: 0;
color: #aeb7cb;
line-height: 1.6;
}
.drop-zone {
min-height: 260px;
padding: 30px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 2px dashed #3d4b70;
border-radius: 18px;
background: #111622;
cursor: pointer;
transition: 160ms ease;
}
.drop-zone:hover,
.drop-zone.is-dragging {
border-color: #7d9cff;
background: #161d2d;
transform: translateY(-2px);
}
.drop-icon {
width: 54px;
height: 54px;
margin-bottom: 7px;
display: grid;
place-items: center;
border-radius: 50%;
color: #0d1018;
background: #8ca9ff;
font-size: 1.7rem;
font-weight: 900;
}
.drop-zone strong {
font-size: 1.15rem;
}
.drop-zone span:not(.drop-icon),
.drop-zone small {
color: #929db3;
}
.workspace {
display: grid;
gap: 24px;
}
.settings {
padding: 20px;
display: grid;
grid-template-columns: 1.4fr 1fr 1fr auto;
align-items: end;
gap: 16px;
border-radius: 16px;
background: #111622;
}
.field {
display: grid;
gap: 9px;
}
.field label {
display: flex;
justify-content: space-between;
color: #cdd4e4;
font-size: 0.88rem;
font-weight: 700;
}
.field output {
color: #8ca9ff;
}
.field small {
min-height: 16px;
color: #778198;
font-size: 0.72rem;
}
select {
width: 100%;
height: 44px;
padding: 0 12px;
color: #e8ecf5;
border: 1px solid #343d54;
border-radius: 10px;
outline: none;
background: #1a2030;
}
input[type="range"] {
width: 100%;
accent-color: #8ca9ff;
}
button {
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: 10px;
font-weight: 800;
cursor: pointer;
}
.primary-button,
#downloadButton {
color: #0c1020;
background: #8ca9ff;
}
button:hover:not(:disabled) {
filter: brightness(1.08);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.secondary-button {
color: #dce3f3;
border: 1px solid #384158;
background: #1a2030;
}
.privacy-note {
grid-column: 1 / -1;
margin: -5px 0 0;
color: #778198;
font-size: 0.78rem;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.preview-card {
min-width: 0;
padding: 16px;
border: 1px solid #2d3549;
border-radius: 16px;
background: #111622;
}
.card-heading {
margin-bottom: 12px;
display: flex;
justify-content: space-between;
gap: 12px;
}
.card-heading span {
color: #8ca9ff;
font-size: 0.9rem;
}
.image-frame {
height: 300px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 12px;
background-color: #0b0e15;
background-image:
linear-gradient(45deg, #171c28 25%, transparent 25%),
linear-gradient(-45deg, #171c28 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #171c28 75%),
linear-gradient(-45deg, transparent 75%, #171c28 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
}
.image-frame img {
width: 100%;
height: 100%;
object-fit: contain;
}
.result-frame p {
padding: 20px;
color: #778198;
text-align: center;
}
.preview-card > p {
min-height: 20px;
margin: 12px 0 0;
color: #929db3;
font-size: 0.82rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.status {
min-height: 22px;
margin: -10px 0 0;
color: #ffbc70;
}
.status.success {
color: #72d6a0;
}
.status.error {
color: #ff8181;
}
@media (max-width: 800px) {
body {
padding: 12px;
}
.settings {
grid-template-columns: 1fr;
}
.privacy-note {
grid-column: auto;
}
.preview-grid {
grid-template-columns: 1fr;
}
.image-frame {
height: 250px;
}
.actions button {
width: 100%;
}
}
JavaScript
const fileInput = document.querySelector("#fileInput");
const dropZone = document.querySelector("#dropZone");
const workspace = document.querySelector("#workspace");
const quality = document.querySelector("#quality");
const qualityValue = document.querySelector("#qualityValue");
const maxWidth = document.querySelector("#maxWidth");
const format = document.querySelector("#format");
const formatNote = document.querySelector("#formatNote");
const compressButton = document.querySelector("#compressButton");
const downloadButton = document.querySelector("#downloadButton");
const resetButton = document.querySelector("#resetButton");
const originalPreview = document.querySelector("#originalPreview");
const resultPreview = document.querySelector("#resultPreview");
const resultPlaceholder = document.querySelector("#resultPlaceholder");
const originalSize = document.querySelector("#originalSize");
const resultSize = document.querySelector("#resultSize");
const originalDetails = document.querySelector("#originalDetails");
const resultDetails = document.querySelector("#resultDetails");
const status = document.querySelector("#status");
const supportedTypes = [
"image/jpeg",
"image/png",
"image/webp"
];
let selectedFile = null;
let originalUrl = "";
let resultUrl = "";
let compressedBlob = null;
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, unitIndex);
return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
}
function showStatus(message = "", type = "") {
status.textContent = message;
status.className = `status ${type}`.trim();
}
function loadImage(file) {
return new Promise((resolve, reject) => {
const temporaryUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(temporaryUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(temporaryUrl);
reject(new Error("The browser could not read this image."));
};
image.src = temporaryUrl;
});
}
async function selectImage(file) {
showStatus();
if (!file) return;
if (!supportedTypes.includes(file.type)) {
showStatus("Please choose a JPEG, PNG or WebP image.", "error");
return;
}
if (file.size > 30 * 1024 * 1024) {
showStatus("This image is larger than 30 MB.", "error");
return;
}
selectedFile = file;
compressedBlob = null;
downloadButton.disabled = true;
if (originalUrl) URL.revokeObjectURL(originalUrl);
if (resultUrl) URL.revokeObjectURL(resultUrl);
originalUrl = URL.createObjectURL(file);
originalPreview.src = originalUrl;
resultPreview.hidden = true;
resultPreview.removeAttribute("src");
resultPlaceholder.hidden = false;
resultSize.textContent = "—";
resultDetails.textContent = "Waiting for compression";
try {
const image = await loadImage(file);
originalSize.textContent = formatBytes(file.size);
originalDetails.textContent =
`${image.naturalWidth} × ${image.naturalHeight}px · ${file.type}`;
dropZone.hidden = true;
workspace.hidden = false;
} catch (error) {
showStatus(error.message, "error");
}
}
async function compressImage() {
if (!selectedFile) return;
compressButton.disabled = true;
compressButton.textContent = "Compressing...";
showStatus("Working on the image...");
try {
const image = await loadImage(selectedFile);
const widthLimit = Number(maxWidth.value);
const scale =
widthLimit > 0 && image.naturalWidth > widthLimit
? widthLimit / image.naturalWidth
: 1;
const outputWidth = Math.round(image.naturalWidth * scale);
const outputHeight = Math.round(image.naturalHeight * scale);
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = outputWidth;
canvas.height = outputHeight;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
if (format.value === "image/jpeg") {
context.fillStyle = "#ffffff";
context.fillRect(0, 0, outputWidth, outputHeight);
}
context.drawImage(image, 0, 0, outputWidth, outputHeight);
compressedBlob = await new Promise((resolve) => {
canvas.toBlob(
resolve,
format.value,
Number(quality.value) / 100
);
});
if (!compressedBlob) {
throw new Error("The browser could not create the new image.");
}
if (resultUrl) URL.revokeObjectURL(resultUrl);
resultUrl = URL.createObjectURL(compressedBlob);
resultPreview.src = resultUrl;
resultPreview.hidden = false;
resultPlaceholder.hidden = true;
const difference =
((selectedFile.size - compressedBlob.size) / selectedFile.size) * 100;
const comparison =
difference >= 0
? `${difference.toFixed(1)}% smaller`
: `${Math.abs(difference).toFixed(1)}% larger`;
resultSize.textContent = formatBytes(compressedBlob.size);
resultDetails.textContent =
`${outputWidth} × ${outputHeight}px · ${comparison}`;
downloadButton.disabled = false;
showStatus("Compression finished.", "success");
} catch (error) {
showStatus(error.message, "error");
} finally {
compressButton.disabled = false;
compressButton.textContent = "Compress image";
}
}
function downloadImage() {
if (!compressedBlob || !resultUrl) return;
const extension = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp"
}[format.value];
const originalName =
selectedFile.name.replace(/\.[^/.]+$/, "") || "image";
const downloadLink = document.createElement("a");
downloadLink.href = resultUrl;
downloadLink.download = `${originalName}-compressed.${extension}`;
downloadLink.click();
}
function resetTool() {
selectedFile = null;
compressedBlob = null;
fileInput.value = "";
if (originalUrl) URL.revokeObjectURL(originalUrl);
if (resultUrl) URL.revokeObjectURL(resultUrl);
originalUrl = "";
resultUrl = "";
originalPreview.removeAttribute("src");
resultPreview.removeAttribute("src");
resultPreview.hidden = true;
resultPlaceholder.hidden = false;
workspace.hidden = true;
dropZone.hidden = false;
showStatus();
}
quality.addEventListener("input", () => {
qualityValue.textContent = `${quality.value}%`;
});
format.addEventListener("change", () => {
formatNote.textContent =
format.value === "image/png"
? "PNG ignores the quality slider and may produce a larger file."
: format.value === "image/jpeg"
? "Transparent areas will be filled with white."
: "WebP usually gives the smallest result.";
});
fileInput.addEventListener("change", (event) => {
selectImage(event.target.files[0]);
});
compressButton.addEventListener("click", compressImage);
downloadButton.addEventListener("click", downloadImage);
resetButton.addEventListener("click", resetTool);
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragging");
});
});
dropZone.addEventListener("drop", (event) => {
selectImage(event.dataTransfer.files[0]);
});
What’s actually happening here?
The selected image is loaded into an HTML canvas. JavaScript redraws it at the requested size and the browser creates a new JPEG, PNG or WebP file using canvas.toBlob().
There’s no hidden upload in the background. Everything happens locally, so closing or refreshing the page removes the selected image.
WebP will usually give the best file-size reduction. JPEG is useful for photos, but transparent areas will turn white. PNG keeps transparency, although changing the quality slider won’t make much difference because browsers don’t apply that setting to PNG output.
One other thing: a smaller quality number doesn’t always guarantee a smaller file. Changing a JPEG to PNG, for example, can make it much larger. That’s why the tool shows both file sizes instead of pretending every result is an improvement.
If you modify the layout or add something useful, share your version below. A target file-size option and batch compression would both be good next additions.