Copy-paste React components for image upload & compression. Built for Next.js + Tailwind CSS — zero dependencies, just drop into your project.
returnStats is set. Supports quality,
format, and onComplete callback.
"use client"; import { useCallback, useRef, useState } from "react"; const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? ""; interface CompressResult { blob: Blob; originalSize: number; compressedSize: number; savedPercent: number; downloadUrl?: string; filename?: string; } interface ImageUploaderProps { quality?: number; // 1-100 (default 80) format?: "webp" | "avif" | "jpeg" | "png"; returnStats?: boolean; // true → JSON mode with download URL accept?: string; // file input accept (default "image/*") maxSizeMB?: number; // max file size in MB (default 10) onComplete?: (r: CompressResult) => void; onError?: (err: string) => void; className?: string; children?: React.ReactNode; // custom drop zone content } export function ImageUploader({ quality = 80, format, returnStats = false, accept = "image/*", maxSizeMB = 10, onComplete, onError, className = "", children, }: ImageUploaderProps) { const inputRef = useRef<HTMLInputElement>(null); const [dragging, setDragging] = useState(false); const [loading, setLoading] = useState(false); const [preview, setPreview] = useState<string | null>(null); const [result, setResult] = useState<CompressResult | null>(null); const [error, setError] = useState<string | null>(null); const compress = useCallback(async (file: File) => { if (file.size > maxSizeMB * 1024 * 1024) { const msg = `File exceeds ${maxSizeMB} MB limit`; setError(msg); onError?.(msg); return; } setError(null); setLoading(true); setResult(null); setPreview(URL.createObjectURL(file)); const params = new URLSearchParams({ quality: String(quality) }); if (format) params.set("format", format); const body = new FormData(); body.append("file", file); try { const headers: Record<string, string> = {}; if (returnStats) headers["Accept"] = "application/json"; const res = await fetch( `${NEXTIMG_URL}/upload/compress?${params}`, { method: "POST", headers, body } ); if (!res.ok) throw new Error(await res.text()); let r: CompressResult; if (returnStats) { const json = await res.json(); const dlPath = new URL(json.downloadUrl, location.href).pathname; r = { blob: await fetch(dlPath).then((r) => r.blob()), originalSize: json.originalSize, compressedSize: json.optimizedSize, savedPercent: json.savedPercent, downloadUrl: dlPath, filename: json.filename, }; } else { const blob = await res.blob(); r = { blob, originalSize: file.size, compressedSize: blob.size, savedPercent: +((1 - blob.size / file.size) * 100).toFixed(1), }; } setResult(r); onComplete?.(r); } catch (e: any) { const msg = e?.message ?? "Compression failed"; setError(msg); onError?.(msg); } finally { setLoading(false); } }, [quality, format, returnStats, maxSizeMB, onComplete, onError]); const onDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files[0]; if (file) compress(file); }, [compress]); return ( <div className={`relative ${className}`}> <div onClick={() => inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} className={` flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-8 cursor-pointer transition-colors duration-200 ${dragging ? "border-indigo-500 bg-indigo-500/5" : "border-zinc-700 hover:border-zinc-500 bg-zinc-900/50" } `} > <input ref={inputRef} type="file" accept={accept} className="hidden" onChange={(e) => e.target.files?.[0] && compress(e.target.files[0])} /> {children ?? ( <> <svg className="w-8 h-8 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> </svg> <p className="text-sm text-zinc-400"> <span className="font-medium text-white">Click to upload</span> or drag & drop </p> <p className="text-xs text-zinc-500">PNG, JPG, WebP, AVIF • max {maxSizeMB}MB</p> </> )} </div> {loading && ( <div className="mt-3 flex items-center gap-2 text-sm text-zinc-400"> <span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-600 border-t-indigo-400" /> Compressing… </div> )} {error && ( <p className="mt-3 text-sm text-red-400">⚠ {error}</p> )} {result && ( <div className="mt-4 rounded-lg border border-zinc-800 bg-zinc-900 p-4"> {preview && ( <img src={URL.createObjectURL(result.blob)} alt="compressed" className="mb-3 max-h-48 rounded-md object-contain" /> )} <div className="grid grid-cols-3 gap-3 text-center text-xs"> <div> <div className="text-lg font-bold">{fmtBytes(result.originalSize)}</div> <div className="text-zinc-500">Original</div> </div> <div> <div className="text-lg font-bold">{fmtBytes(result.compressedSize)}</div> <div className="text-zinc-500">Compressed</div> </div> <div> <div className={`text-lg font-bold ${ result.savedPercent > 0 ? "text-green-400" : "text-red-400" }`}> {result.savedPercent > 0 ? "−" : "+"}{Math.abs(result.savedPercent)}% </div> <div className="text-zinc-500">Saved</div> </div> </div> </div> )} </div> ); } function fmtBytes(b: number): string { if (b < 1024) return b + " B"; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + " KB"; return (b / 1024 / 1024).toFixed(2) + " MB"; }
import { ImageUploader } from "@/components/image-uploader"; export default function Page() { return ( <ImageUploader quality={75} format="webp" onComplete={(r) => console.log("Compressed!", r)} className="max-w-md mx-auto" /> ); } // With JSON stats mode (returns download URL + server-side stats): <ImageUploader returnStats quality={60} format="avif" /> // Custom drop zone content: <ImageUploader quality={80}> <p>Drop your photo here</p> </ImageUploader>
compress(file), loading/error state,
and typed result — wire it to any file input or custom drop
zone.
"use client"; import { useCallback, useState } from "react"; const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? ""; interface CompressOptions { quality?: number; format?: "webp" | "avif" | "jpeg" | "png"; returnStats?: boolean; } interface CompressResult { blob: Blob; url: string; // object URL for preview originalSize: number; compressedSize: number; savedPercent: number; downloadUrl?: string; // server path (stats mode) filename?: string; } export function useImageCompress(opts: CompressOptions = {}) { const { quality = 80, format, returnStats = false } = opts; const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [result, setResult] = useState<CompressResult | null>(null); const compress = useCallback(async (file: File) => { setLoading(true); setError(null); setResult(null); const params = new URLSearchParams({ quality: String(quality) }); if (format) params.set("format", format); const body = new FormData(); body.append("file", file); try { const headers: Record<string, string> = {}; if (returnStats) headers["Accept"] = "application/json"; const res = await fetch( `${NEXTIMG_URL}/upload/compress?${params}`, { method: "POST", headers, body } ); if (!res.ok) throw new Error(await res.text()); if (returnStats) { const json = await res.json(); const dlPath = new URL(json.downloadUrl, location.href).pathname; const blob = await fetch(dlPath).then((r) => r.blob()); setResult({ blob, url: URL.createObjectURL(blob), originalSize: json.originalSize, compressedSize: json.optimizedSize, savedPercent: json.savedPercent, downloadUrl: dlPath, filename: json.filename, }); } else { const blob = await res.blob(); setResult({ blob, url: URL.createObjectURL(blob), originalSize: file.size, compressedSize: blob.size, savedPercent: +(((1 - blob.size / file.size) * 100).toFixed(1)), }); } } catch (e: any) { setError(e?.message ?? "Compression failed"); } finally { setLoading(false); } }, [quality, format, returnStats]); const reset = useCallback(() => { if (result?.url) URL.revokeObjectURL(result.url); setResult(null); setError(null); }, [result]); return { compress, reset, loading, error, result } as const; }
"use client"; import { useImageCompress } from "@/hooks/use-image-compress"; export default function MyUploadPage() { const { compress, loading, error, result, reset } = useImageCompress({ quality: 75, format: "webp", }); return ( <div className="p-8 max-w-md mx-auto"> <input type="file" accept="image/*" onChange={(e) => e.target.files?.[0] && compress(e.target.files[0])} /> {loading && <p>Compressing…</p>} {error && <p className="text-red-400">{error}</p>} {result && ( <div> <img src={result.url} alt="compressed" className="max-h-64 rounded" /> <p>{result.compressedSize} bytes ({result.savedPercent}% saved)</p> <button onClick={reset}>Reset</button> </div> )} </div> ); }
originalSrc and compressedSrc
(blob URLs or regular URLs) and it renders a draggable divider.
Works great with the hook above.
"use client"; import { useCallback, useRef, useState } from "react"; interface ImageCompareSliderProps { originalSrc: string; compressedSrc: string; height?: number; // px, default 300 className?: string; } export function ImageCompareSlider({ originalSrc, compressedSrc, height = 300, className = "", }: ImageCompareSliderProps) { const containerRef = useRef<HTMLDivElement>(null); const [pos, setPos] = useState(50); const dragging = useRef(false); const updatePos = useCallback((clientX: number) => { const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100 )); setPos(pct); }, []); const onPointerDown = useCallback((e: React.PointerEvent) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); updatePos(e.clientX); }, [updatePos]); const onPointerMove = useCallback((e: React.PointerEvent) => { if (dragging.current) updatePos(e.clientX); }, [updatePos]); const onPointerUp = useCallback(() => { dragging.current = false; }, []); return ( <div ref={containerRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} className={` relative select-none cursor-col-resize overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 ${className} `} style={{ height }} > {/* Original (right side / base) */} <img src={originalSrc} alt="original" className="absolute inset-0 h-full w-full object-contain" draggable={false} /> {/* Compressed (left side, clipped) */} <div className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${pos}%` }} > <img src={compressedSrc} alt="compressed" className="h-full object-contain" style={{ width: containerRef.current?.offsetWidth }} draggable={false} /> </div> {/* Divider line */} <div className="absolute inset-y-0 w-0.5 bg-white/80 -translate-x-1/2" style={{ left: `${pos}%` }} > <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white shadow-lg flex items-center justify-center"> <span className="text-zinc-600 text-sm">↔</span> </div> </div> {/* Labels */} <span className="absolute top-2 left-2 text-[10px] font-bold uppercase tracking-wider bg-black/50 text-white px-2 py-0.5 rounded" >Compressed</span> <span className="absolute top-2 right-2 text-[10px] font-bold uppercase tracking-wider bg-black/50 text-white px-2 py-0.5 rounded" >Original</span> </div> ); }
import { ImageCompareSlider } from "@/components/image-compare-slider"; // Using with the useImageCompress hook: const { compress, result } = useImageCompress({ quality: 70 }); {result && ( <ImageCompareSlider originalSrc={originalPreviewUrl} compressedSrc={result.url} height={400} className="max-w-xl" /> )} // Or with static URLs: <ImageCompareSlider originalSrc="/photos/original.jpg" compressedSrc="http://localhost:3000/compress/https://example.com/photo.jpg?quality=60" height={350} />
<img> replacement that proxies the
src through NextImg for on-the-fly compression.
Ideal for displaying remote images already optimized — no upload
needed.
import React from "react"; const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? ""; interface CompressedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { src: string; // full image URL quality?: number; // 1-100 (default 80) format?: "webp" | "avif" | "jpeg" | "png"; width?: number; height?: number; } export function CompressedImage({ src, quality = 80, format, width, height, ...props }: CompressedImageProps) { const params = new URLSearchParams({ quality: String(quality) }); if (format) params.set("format", format); if (width) params.set("width", String(width)); if (height) params.set("height", String(height)); // Use /compress/ for optimization, /image/ for resize const endpoint = width || height ? "image" : "compress"; const proxiedSrc = `${NEXTIMG_URL}/${endpoint}/${src}?${params}`; return <img src={proxiedSrc} loading="lazy" {...props} />; }
import { CompressedImage } from "@/components/compressed-image"; // Compress a remote image on-the-fly <CompressedImage src="https://images.unsplash.com/photo-123456" quality={70} format="webp" alt="My photo" className="w-full rounded-xl" /> // Resize + compress <CompressedImage src="https://example.com/hero.png" width={800} height={600} quality={60} format="avif" alt="Hero" />
const NEXTIMG_URL = process.env.NEXT_PUBLIC_NEXTIMG_URL ?? ""; interface CompressImageOptions { quality?: number; format?: "webp" | "avif" | "jpeg" | "png"; } interface CompressImageResult { blob: Blob; originalSize: number; compressedSize: number; savedPercent: number; } export async function compressImage( file: File, opts: CompressImageOptions = {} ): Promise<CompressImageResult> { const { quality = 80, format } = opts; const params = new URLSearchParams({ quality: String(quality) }); if (format) params.set("format", format); const body = new FormData(); body.append("file", file); const res = await fetch( `${NEXTIMG_URL}/upload/compress?${params}`, { method: "POST", body } ); if (!res.ok) throw new Error(await res.text()); const blob = await res.blob(); return { blob, originalSize: file.size, compressedSize: blob.size, savedPercent: +(((1 - blob.size / file.size) * 100).toFixed(1)), }; }
import { compressImage } from "@/lib/compress-image"; // In a form submit handler: async function handleSubmit(form: FormData) { const file = form.get("avatar") as File; const { blob, savedPercent } = await compressImage(file, { quality: 70, format: "webp", }); console.log(`Saved ${savedPercent}%`); // Upload the compressed blob to your storage const uploadForm = new FormData(); uploadForm.append("file", blob, "avatar.webp"); await fetch("/api/upload", { method: "POST", body: uploadForm }); } // In a Next.js API route / Server Action: const result = await compressImage(file, { quality: 80 }); // result.blob → write to S3, disk, etc.