Skip to main content
Back to Blog
Tutorials
4 min read
December 15, 2024

How to Build an Image Editor and Cropper in React

Build an image editor in React with crop, rotate, flip, brightness, contrast, and zoom using the Canvas API.

Ryel Banfield

Founder & Lead Developer

Image editing features add value to any content platform. Here is how to build a crop and filter editor using the Canvas API.

Install react-image-crop

pnpm add react-image-crop

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 {
  src: string;
  onCropComplete: (blob: Blob) => void;
  aspect?: number;
}

export function ImageCropper({ src, onCropComplete, aspect }: ImageCropperProps) {
  const [crop, setCrop] = useState<Crop>();
  const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
  const imgRef = useRef<HTMLImageElement>(null);

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

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

    const image = imgRef.current;
    const scaleX = image.naturalWidth / image.width;
    const scaleY = image.naturalHeight / image.height;

    canvas.width = completedCrop.width * scaleX;
    canvas.height = completedCrop.height * scaleY;

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

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

  return (
    <div className="space-y-4">
      <ReactCrop
        crop={crop}
        onChange={(c) => setCrop(c)}
        onComplete={(c) => setCompletedCrop(c)}
        aspect={aspect}
        className="max-h-[500px]"
      >
        <img
          ref={imgRef}
          src={src}
          alt="Crop preview"
          className="max-w-full"
          crossOrigin="anonymous"
        />
      </ReactCrop>

      <div className="flex gap-2">
        {[
          { label: "Free", value: undefined },
          { label: "1:1", value: 1 },
          { label: "16:9", value: 16 / 9 },
          { label: "4:3", value: 4 / 3 },
          { label: "3:2", value: 3 / 2 },
        ].map(({ label, value }) => (
          <button
            key={label}
            onClick={() => {
              setCrop(undefined);
              // Aspect is controlled via the aspect prop
            }}
            className="px-3 py-1 text-xs border rounded hover:bg-muted"
          >
            {label}
          </button>
        ))}
      </div>

      <button
        onClick={handleCropImage}
        disabled={!completedCrop}
        className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm disabled:opacity-50"
      >
        Apply Crop
      </button>
    </div>
  );
}

Canvas-Based Image Editor

"use client";

import { useRef, useState, useEffect, useCallback } from "react";

interface ImageEditorProps {
  src: string;
  onSave: (blob: Blob) => void;
}

interface Adjustments {
  brightness: number;
  contrast: number;
  saturation: number;
  rotation: number;
  flipH: boolean;
  flipV: boolean;
}

const DEFAULT_ADJUSTMENTS: Adjustments = {
  brightness: 100,
  contrast: 100,
  saturation: 100,
  rotation: 0,
  flipH: false,
  flipV: false,
};

export function ImageEditor({ src, onSave }: ImageEditorProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const imageRef = useRef<HTMLImageElement | null>(null);
  const [adjustments, setAdjustments] = useState<Adjustments>(DEFAULT_ADJUSTMENTS);
  const [zoom, setZoom] = useState(1);

  // Load image
  useEffect(() => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      imageRef.current = img;
      renderCanvas();
    };
    img.src = src;
  }, [src]);

  const renderCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    const img = imageRef.current;
    if (!canvas || !ctx || !img) return;

    const { rotation, flipH, flipV, brightness, contrast, saturation } = adjustments;
    const isRotated = rotation === 90 || rotation === 270;

    canvas.width = isRotated ? img.naturalHeight : img.naturalWidth;
    canvas.height = isRotated ? img.naturalWidth : img.naturalHeight;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.save();

    // Apply transforms
    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate((rotation * Math.PI) / 180);
    ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
    ctx.scale(zoom, zoom);

    // Apply CSS filters
    ctx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%)`;

    ctx.drawImage(
      img,
      -img.naturalWidth / 2,
      -img.naturalHeight / 2,
      img.naturalWidth,
      img.naturalHeight
    );

    ctx.restore();
  }, [adjustments, zoom]);

  useEffect(() => {
    renderCanvas();
  }, [renderCanvas]);

  const updateAdjustment = <K extends keyof Adjustments>(
    key: K,
    value: Adjustments[K]
  ) => {
    setAdjustments((prev) => ({ ...prev, [key]: value }));
  };

  const handleSave = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.toBlob(
      (blob) => {
        if (blob) onSave(blob);
      },
      "image/jpeg",
      0.9
    );
  };

  const handleReset = () => {
    setAdjustments(DEFAULT_ADJUSTMENTS);
    setZoom(1);
  };

  return (
    <div className="flex gap-6">
      {/* Canvas Preview */}
      <div className="flex-1 bg-muted/30 rounded-lg flex items-center justify-center overflow-hidden p-4">
        <canvas
          ref={canvasRef}
          className="max-w-full max-h-[500px] object-contain"
        />
      </div>

      {/* Controls */}
      <div className="w-64 space-y-5">
        <h3 className="font-semibold text-sm">Adjustments</h3>

        <SliderControl
          label="Brightness"
          value={adjustments.brightness}
          min={0}
          max={200}
          onChange={(v) => updateAdjustment("brightness", v)}
        />
        <SliderControl
          label="Contrast"
          value={adjustments.contrast}
          min={0}
          max={200}
          onChange={(v) => updateAdjustment("contrast", v)}
        />
        <SliderControl
          label="Saturation"
          value={adjustments.saturation}
          min={0}
          max={200}
          onChange={(v) => updateAdjustment("saturation", v)}
        />
        <SliderControl
          label="Zoom"
          value={zoom * 100}
          min={25}
          max={300}
          onChange={(v) => setZoom(v / 100)}
        />

        <div>
          <h4 className="text-xs font-medium text-muted-foreground mb-2">
            Transform
          </h4>
          <div className="flex flex-wrap gap-1">
            <button
              onClick={() =>
                updateAdjustment("rotation", (adjustments.rotation + 90) % 360)
              }
              className="px-2 py-1 text-xs border rounded hover:bg-muted"
            >
              Rotate 90
            </button>
            <button
              onClick={() => updateAdjustment("flipH", !adjustments.flipH)}
              className={`px-2 py-1 text-xs border rounded ${
                adjustments.flipH ? "bg-primary text-primary-foreground" : "hover:bg-muted"
              }`}
            >
              Flip H
            </button>
            <button
              onClick={() => updateAdjustment("flipV", !adjustments.flipV)}
              className={`px-2 py-1 text-xs border rounded ${
                adjustments.flipV ? "bg-primary text-primary-foreground" : "hover:bg-muted"
              }`}
            >
              Flip V
            </button>
          </div>
        </div>

        <div className="flex gap-2 pt-4 border-t">
          <button
            onClick={handleReset}
            className="flex-1 px-3 py-2 text-sm border rounded hover:bg-muted"
          >
            Reset
          </button>
          <button
            onClick={handleSave}
            className="flex-1 px-3 py-2 text-sm bg-primary text-primary-foreground rounded"
          >
            Save
          </button>
        </div>
      </div>
    </div>
  );
}

function SliderControl({
  label,
  value,
  min,
  max,
  onChange,
}: {
  label: string;
  value: number;
  min: number;
  max: number;
  onChange: (v: number) => void;
}) {
  return (
    <div>
      <div className="flex justify-between text-xs mb-1">
        <span>{label}</span>
        <span className="text-muted-foreground">{Math.round(value)}</span>
      </div>
      <input
        type="range"
        min={min}
        max={max}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="w-full accent-primary"
      />
    </div>
  );
}

Usage

"use client";

import { useState } from "react";
import { ImageEditor } from "@/components/ImageEditor";

export function AvatarUpload() {
  const [image, setImage] = useState<string | null>(null);

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      setImage(URL.createObjectURL(file));
    }
  };

  const handleSave = async (blob: Blob) => {
    const formData = new FormData();
    formData.append("avatar", blob, "avatar.jpg");
    await fetch("/api/upload/avatar", { method: "POST", body: formData });
    setImage(null);
  };

  return (
    <div>
      {image ? (
        <ImageEditor src={image} onSave={handleSave} />
      ) : (
        <label className="block border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50">
          <input type="file" accept="image/*" onChange={handleFileSelect} className="hidden" />
          <p className="text-sm text-muted-foreground">Click to upload an image</p>
        </label>
      )}
    </div>
  );
}

Need Image Editing Features?

We build custom media tools and image processing pipelines. Contact us to discuss your project.

image editingcropcanvasReacttutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles