Inputs
Image Uploader
Upload, crop, zoom, rotate, and confirm an image with a polished interactive flow.
Live preview
Installation
npx aura-ui add image-uploader
Snippet
import ImageUploader from "@/app/(site)/components/ImageUploader/ImageUploader";
export default function Demo() {
return <ImageUploader />;
}Full code
"use client";
import React, { useRef, useState } from "react";
import { Check, ImageUp, RotateCw, Upload, ZoomIn } from "lucide-react";
const FRAME_SIZE = 208;
export default function Input13() {
const inputRef = useRef<HTMLInputElement>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOrigin, setDragOrigin] = useState({ x: 0, y: 0 });
const selectFile = (file?: File) => {
if (!file?.type.startsWith("image/")) return;
if (imageUrl) URL.revokeObjectURL(imageUrl);
setImageUrl(URL.createObjectURL(file));
setResultUrl(null);
setZoom(1);
setRotation(0);
setOffset({ x: 0, y: 0 });
};
const confirmImage = () => {
if (!imageUrl) return;
const image = new Image();
image.onload = () => {
const size = 320;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
if (!context) return;
const scale =
Math.max(size / image.naturalWidth, size / image.naturalHeight) * zoom;
context.save();
context.beginPath();
context.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
context.clip();
context.translate(size / 2 + offset.x, size / 2 + offset.y);
context.rotate((rotation * Math.PI) / 180);
context.scale(scale, scale);
context.drawImage(
image,
-image.naturalWidth / 2,
-image.naturalHeight / 2,
);
context.restore();
setResultUrl(canvas.toDataURL("image/png"));
};
image.src = imageUrl;
};
const reset = () => {
if (imageUrl) URL.revokeObjectURL(imageUrl);
setImageUrl(null);
setResultUrl(null);
setZoom(1);
setRotation(0);
setOffset({ x: 0, y: 0 });
};
if (resultUrl) {
return (
<div className="image-uploader image-uploader--result">
<div className="image-uploader__result-wrap">
<img src={resultUrl} alt="Cropped upload preview" />
<span>
<Check size={14} />
</span>
</div>
<strong>Photo ready</strong>
<small>Cropped and optimized for upload.</small>
<button type="button" onClick={reset}>
Choose another photo
</button>
<style dangerouslySetInnerHTML={{ __html: styles }} />
</div>
);
}
return (
<div className="image-uploader">
{!imageUrl ? (
<>
<button
type="button"
className="image-uploader__dropzone"
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
selectFile(event.dataTransfer.files[0]);
}}
>
<span>
<ImageUp size={20} />
</span>
<strong>Drag & drop an image</strong>
<small>PNG, JPG, WEBP</small>
</button>
<input
ref={inputRef}
hidden
type="file"
accept="image/*"
onChange={(event) => selectFile(event.target.files?.[0])}
/>
<button
type="button"
className="image-uploader__outline"
onClick={() => inputRef.current?.click()}
>
Browse image
</button>
</>
) : (
<>
<div
className={`image-uploader__crop ${dragging ? "is-dragging" : ""}`}
onPointerDown={(event) => {
setDragging(true);
setDragStart({ x: event.clientX, y: event.clientY });
setDragOrigin(offset);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (!dragging) return;
setOffset({
x: dragOrigin.x + event.clientX - dragStart.x,
y: dragOrigin.y + event.clientY - dragStart.y,
});
}}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
>
<img
src={imageUrl}
alt="Crop preview"
draggable={false}
style={{
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) rotate(${rotation}deg) scale(${zoom})`,
}}
/>
<i />
</div>
<label className="image-uploader__slider">
<ZoomIn size={15} />
<input
type="range"
min="1"
max="3"
step="0.01"
value={zoom}
onChange={(event) => setZoom(Number(event.target.value))}
/>
</label>
<div className="image-uploader__actions">
<button
type="button"
className="image-uploader__outline"
onClick={() => setRotation((value) => (value + 90) % 360)}
>
<RotateCw size={14} /> Rotate
</button>
<div>
<button
type="button"
className="image-uploader__ghost"
onClick={reset}
>
Cancel
</button>
<button
type="button"
className="image-uploader__solid"
onClick={confirmImage}
>
<Upload size={14} /> Use photo
</button>
</div>
</div>
</>
)}
<style dangerouslySetInnerHTML={{ __html: styles }} />
</div>
);
}
const styles = `
.image-uploader { display:flex; width:100%; max-width:320px; flex-direction:column; align-items:center; gap:16px; color:var(--color-fg,#111827); font-family:inherit; }
.image-uploader button { font:inherit; }
.image-uploader__dropzone { width:208px; height:208px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; border:2px dashed color-mix(in srgb,var(--color-fg,#111827) 25%,transparent); border-radius:50%; background:color-mix(in srgb,var(--color-fg,#111827) 4%,transparent); color:inherit; cursor:pointer; transition:transform .2s,border-color .2s,background .2s; }
.image-uploader__dropzone:hover { transform:scale(1.02); border-color:var(--color-accent-primary,#10b981); background:color-mix(in srgb,var(--color-accent-primary,#10b981) 8%,transparent); }
.image-uploader__dropzone span { display:grid; place-items:center; width:40px; height:40px; border:1px dashed currentColor; border-radius:50%; opacity:.7; }
.image-uploader small { font-size:11px; opacity:.6; }
.image-uploader__outline,.image-uploader__ghost,.image-uploader__solid { display:inline-flex; align-items:center; gap:7px; border:0; border-radius:9px; padding:8px 12px; cursor:pointer; transition:transform .2s,filter .2s,background .2s; }
.image-uploader__outline { border:1px solid color-mix(in srgb,var(--color-fg,#111827) 20%,transparent); background:transparent; color:inherit; }
.image-uploader__outline:hover,.image-uploader__ghost:hover,.image-uploader__solid:hover { transform:translateY(-1px); filter:brightness(1.08); }
.image-uploader__crop { position:relative; width:${FRAME_SIZE}px; height:${FRAME_SIZE}px; overflow:hidden; border:2px dashed color-mix(in srgb,var(--color-accent-primary,#10b981) 55%,transparent); border-radius:50%; background:color-mix(in srgb,var(--color-fg,#111827) 8%,transparent); cursor:grab; touch-action:none; }
.image-uploader__crop.is-dragging { cursor:grabbing; }
.image-uploader__crop img { position:absolute; left:50%; top:50%; max-width:none; user-select:none; pointer-events:none; transform-origin:center; }
.image-uploader__crop i { position:absolute; inset:0; border-radius:50%; box-shadow:inset 0 0 0 1px rgba(255,255,255,.45); pointer-events:none; }
.image-uploader__slider { display:flex; width:100%; align-items:center; gap:10px; }
.image-uploader__slider input { width:100%; accent-color:var(--color-accent-primary,#10b981); }
.image-uploader__actions { display:flex; width:100%; align-items:center; justify-content:space-between; gap:8px; }
.image-uploader__actions > div { display:flex; gap:6px; }
.image-uploader__ghost { background:transparent; color:inherit; opacity:.7; }
.image-uploader__solid { background:var(--color-accent-primary,#10b981); color:#fff; }
.image-uploader--result { text-align:center; }
.image-uploader__result-wrap { position:relative; }
.image-uploader__result-wrap img { width:146px; height:146px; object-fit:cover; border:1px solid color-mix(in srgb,var(--color-fg,#111827) 18%,transparent); border-radius:50%; }
.image-uploader__result-wrap span { position:absolute; right:2px; bottom:4px; display:grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#14b8a6; color:#fff; }
`;