Skip to main content
Back to Blog
Tutorials
5 min read
November 24, 2024

How to Implement Image Cropping and Resizing in React

Build an image cropping and resizing tool with react-image-crop, aspect ratio control, and upload preview.

Ryel Banfield

Founder & Lead Developer

Image cropping is essential for profile pictures, thumbnails, and content images. Here is how to build it.

Step 1: Install Dependencies

pnpm add react-image-crop

Step 2: Image Cropper Component

"use client";

import { useState, useRef, useCallback } from "react";
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";

interface ImageCropperProps {
  onCropComplete: (blob: Blob) => void;
  aspectRatio?: number;
  maxWidth?: number;
  maxHeight?: number;
  circularCrop?: boolean;
}

export function ImageCropper({
  onCropComplete,
  aspectRatio,
  maxWidth = 800,
  maxHeight = 800,
  circularCrop = false,
}: ImageCropperProps) {
  const [imageSrc, setImageSrc] = useState<string | null>(null);
  const [crop, setCrop] = useState<Crop>();
  const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
  const imgRef = useRef<HTMLImageElement>(null);

  function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    // Validate file type
    if (!file.type.startsWith("image/")) {
      alert("Please select an image file");
      return;
    }

    // Validate file size (10MB max)
    if (file.size > 10 * 1024 * 1024) {
      alert("File size must be under 10MB");
      return;
    }

    const reader = new FileReader();
    reader.addEventListener("load", () => {
      setImageSrc(reader.result as string);
    });
    reader.readAsDataURL(file);
  }

  function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
    const { width, height } = e.currentTarget;

    // Default crop centered at 50%
    const cropWidth = Math.min(width * 0.8, maxWidth);
    const cropHeight = aspectRatio ? cropWidth / aspectRatio : Math.min(height * 0.8, maxHeight);

    setCrop({
      unit: "px",
      x: (width - cropWidth) / 2,
      y: (height - cropHeight) / 2,
      width: cropWidth,
      height: cropHeight,
    });
  }

  const getCroppedImage = useCallback(async () => {
    const image = imgRef.current;
    if (!image || !completedCrop) return;

    const canvas = document.createElement("canvas");
    const scaleX = image.naturalWidth / image.width;
    const scaleY = image.naturalHeight / image.height;

    const outputWidth = Math.min(completedCrop.width * scaleX, maxWidth);
    const outputHeight = Math.min(completedCrop.height * scaleY, maxHeight);

    canvas.width = outputWidth;
    canvas.height = outputHeight;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    if (circularCrop) {
      ctx.beginPath();
      ctx.arc(outputWidth / 2, outputHeight / 2, Math.min(outputWidth, outputHeight) / 2, 0, Math.PI * 2);
      ctx.closePath();
      ctx.clip();
    }

    ctx.drawImage(
      image,
      completedCrop.x * scaleX,
      completedCrop.y * scaleY,
      completedCrop.width * scaleX,
      completedCrop.height * scaleY,
      0,
      0,
      outputWidth,
      outputHeight
    );

    canvas.toBlob(
      (blob) => {
        if (blob) onCropComplete(blob);
      },
      "image/jpeg",
      0.9
    );
  }, [completedCrop, circularCrop, maxWidth, maxHeight, onCropComplete]);

  return (
    <div className="space-y-4">
      {!imageSrc ? (
        <label className="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 transition-colors hover:border-blue-500 dark:border-gray-700">
          <p className="mb-2 text-sm font-medium">Click to select an image</p>
          <p className="text-xs text-gray-400">JPG, PNG, or WebP. Max 10MB.</p>
          <input
            type="file"
            accept="image/jpeg,image/png,image/webp"
            onChange={onSelectFile}
            className="hidden"
          />
        </label>
      ) : (
        <>
          <ReactCrop
            crop={crop}
            onChange={(c) => setCrop(c)}
            onComplete={(c) => setCompletedCrop(c)}
            aspect={aspectRatio}
            circularCrop={circularCrop}
          >
            <img
              ref={imgRef}
              src={imageSrc}
              alt="Crop preview"
              onLoad={onImageLoad}
              className="max-h-[500px] w-auto"
            />
          </ReactCrop>

          <div className="flex gap-2">
            <button
              onClick={() => {
                setImageSrc(null);
                setCrop(undefined);
                setCompletedCrop(undefined);
              }}
              className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-700"
            >
              Change Image
            </button>
            <button
              onClick={getCroppedImage}
              disabled={!completedCrop}
              className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
            >
              Apply Crop
            </button>
          </div>
        </>
      )}
    </div>
  );
}

Step 3: Avatar Upload with Crop

"use client";

import { useState } from "react";
import { ImageCropper } from "./ImageCropper";
import { Camera } from "lucide-react";

export function AvatarUpload({ currentAvatar }: { currentAvatar?: string }) {
  const [showCropper, setShowCropper] = useState(false);
  const [preview, setPreview] = useState<string | null>(currentAvatar ?? null);
  const [isUploading, setIsUploading] = useState(false);

  async function handleCropComplete(blob: Blob) {
    // Show preview immediately
    const url = URL.createObjectURL(blob);
    setPreview(url);
    setShowCropper(false);

    // Upload to server
    setIsUploading(true);
    const formData = new FormData();
    formData.append("avatar", blob, "avatar.jpg");

    try {
      const res = await fetch("/api/upload/avatar", {
        method: "POST",
        body: formData,
      });
      const { url: uploadedUrl } = await res.json();
      setPreview(uploadedUrl);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setIsUploading(false);
    }
  }

  return (
    <div>
      {showCropper ? (
        <div className="max-w-lg">
          <ImageCropper
            onCropComplete={handleCropComplete}
            aspectRatio={1}
            maxWidth={400}
            maxHeight={400}
            circularCrop
          />
        </div>
      ) : (
        <div className="flex items-center gap-4">
          <div className="relative">
            <div className="h-20 w-20 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
              {preview ? (
                <img src={preview} alt="Avatar" className="h-full w-full object-cover" />
              ) : (
                <div className="flex h-full items-center justify-center text-2xl font-bold text-gray-400">
                  ?
                </div>
              )}
            </div>
            {isUploading && (
              <div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
                <div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent" />
              </div>
            )}
          </div>

          <button
            onClick={() => setShowCropper(true)}
            className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
          >
            <Camera className="h-4 w-4" />
            Change Photo
          </button>
        </div>
      )}
    </div>
  );
}

Step 4: Batch Image Resizer

"use client";

import { useState, useCallback } from "react";

interface ResizePreset {
  name: string;
  width: number;
  height: number;
}

const PRESETS: ResizePreset[] = [
  { name: "Thumbnail", width: 150, height: 150 },
  { name: "Small", width: 320, height: 240 },
  { name: "Medium", width: 640, height: 480 },
  { name: "Large", width: 1280, height: 960 },
  { name: "Social (1200x630)", width: 1200, height: 630 },
  { name: "Instagram (1080x1080)", width: 1080, height: 1080 },
];

function resizeImage(
  file: File,
  maxWidth: number,
  maxHeight: number,
  quality: number = 0.85
): Promise<Blob> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      let { width, height } = img;

      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height);
        width *= ratio;
        height *= ratio;
      }

      const canvas = document.createElement("canvas");
      canvas.width = width;
      canvas.height = height;

      const ctx = canvas.getContext("2d");
      if (!ctx) {
        reject(new Error("Could not get canvas context"));
        return;
      }

      ctx.drawImage(img, 0, 0, width, height);
      canvas.toBlob(
        (blob) => {
          if (blob) resolve(blob);
          else reject(new Error("Could not create blob"));
        },
        file.type === "image/png" ? "image/png" : "image/jpeg",
        quality
      );
    };
    img.onerror = reject;
    img.src = URL.createObjectURL(file);
  });
}

export function BatchResizer() {
  const [files, setFiles] = useState<File[]>([]);
  const [selectedPreset, setSelectedPreset] = useState<ResizePreset>(PRESETS[2]);
  const [quality, setQuality] = useState(85);
  const [results, setResults] = useState<{ name: string; blob: Blob; size: number }[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);

  const processImages = useCallback(async () => {
    setIsProcessing(true);
    const processed: typeof results = [];

    for (const file of files) {
      const blob = await resizeImage(
        file,
        selectedPreset.width,
        selectedPreset.height,
        quality / 100
      );
      processed.push({ name: file.name, blob, size: blob.size });
    }

    setResults(processed);
    setIsProcessing(false);
  }, [files, selectedPreset, quality]);

  function downloadAll() {
    results.forEach(({ name, blob }) => {
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `resized-${name}`;
      a.click();
      URL.revokeObjectURL(url);
    });
  }

  return (
    <div className="space-y-4">
      <input
        type="file"
        accept="image/*"
        multiple
        onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
        className="block w-full text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-blue-600 file:px-4 file:py-2 file:text-white"
      />

      <div className="flex flex-wrap gap-2">
        {PRESETS.map((preset) => (
          <button
            key={preset.name}
            onClick={() => setSelectedPreset(preset)}
            className={`rounded-lg px-3 py-1.5 text-xs ${
              selectedPreset.name === preset.name
                ? "bg-blue-600 text-white"
                : "bg-gray-100 dark:bg-gray-800"
            }`}
          >
            {preset.name} ({preset.width}x{preset.height})
          </button>
        ))}
      </div>

      <div className="flex items-center gap-3">
        <label className="text-sm">Quality: {quality}%</label>
        <input
          type="range"
          min={10}
          max={100}
          value={quality}
          onChange={(e) => setQuality(Number(e.target.value))}
          className="flex-1"
        />
      </div>

      <button
        onClick={processImages}
        disabled={files.length === 0 || isProcessing}
        className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
      >
        {isProcessing ? "Processing..." : `Resize ${files.length} Image${files.length !== 1 ? "s" : ""}`}
      </button>

      {results.length > 0 && (
        <div>
          <div className="mb-2 flex justify-between">
            <span className="text-sm font-medium">{results.length} images processed</span>
            <button onClick={downloadAll} className="text-sm text-blue-600 hover:underline">
              Download All
            </button>
          </div>
          <div className="space-y-1">
            {results.map((r) => (
              <div key={r.name} className="flex items-center justify-between text-sm">
                <span className="truncate">{r.name}</span>
                <span className="text-gray-400">{(r.size / 1024).toFixed(0)} KB</span>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Summary

  • react-image-crop provides interactive cropping with aspect ratio constraints
  • Canvas API handles resizing and format conversion
  • Circular crop mode for avatar uploads
  • Batch processing with quality control and presets

Need Image Management Features?

We build media management systems with upload, crop, and optimization workflows. Contact us to get started.

imagecroppingresizinguploadReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles