Uploading and saving user-generated images is a very common use-case on web applications. But when I looked around for a solution that worked for my needs, I wasn't able to find one. All of the options fell into one of 2 categories: Vendor-locked libraries provided by image storage companies, which didn't provide the freedom I needed for my project, or extremely simplistic and ugly-looking options that didn't provide the sleek UX I was looking for.
Now, it's entirely possible I'm just bad at googling and there are great options out there, however being the go-getter that I am, I decided to make my own from scratch! So here's the story of how I accomplished it in a simplistic, yet fully-featured way.
Design Process
But first, let's look at the requirements for this image uploader:
- Sleek design that can drop-in to any future projects I build (reusability is key!).
- Customizability for adapting to different use cases - circular profile images and regular aspect-ratio images being 2 key use cases.
- Ability for user to crop or edit images in a basic way upon upload.
- Output should be a base64 string I can upload to a database of my choosing as a BLOB.
- Controls on the dev side to prevent malicious use (limiting file size/type, restricting upload volume, etc.)
So with these 5 key requirements, I set out to start building!
Getting up and Running
To start with, I used my favorite command-line tool to generate a new React project, provided by Vite:
npm create vite@latest
This allows you to choose from a few options, but I went for the react-ts option as it automatically sets up typescript support. I also chose their experimental rust compiler as it provides far better performance than traditional Webpack, particularly when hot-reloading during development.
After that, I download my only other 2 dependencies:
- react-cropper, an awesome FOSS project that provides everything I need for image cropping functionality, and
- storybook, which will allow me and others to easily test the look and feel of my component in a browser on the go. While technically not a dependency for building this image uploader, it's still necessary for what I want to achieve.
And that's it, time to code!
The Code
For my component, I want drag-and-drop functionality, as well as the ability to click-to-upload. To accomplish this, I used a div with an onDrop handler, and inside it I added a traditional file input:
<div id="drop-zone" onDrop={() => dropHandler(event)} onDragOver={() => dragOverHandler(event)}>
<p id="drop-label">Click or drag a file to <i>upload</i>.</p>
<input id="image-input" type="file" accept=".png,.jpg,.jpeg,.gif" onInput={(e) => {handleFile(e)}} />
{fileInput && <p id="file-name">{fileName}</p>}
</div>
As you'll notice, the input allows me to limit accepted file types to the most common image formats. But this only works on the click-to-upload. For the dropHandler function, I do things a bit differently:
const dropHandler = (ev: any) => {
ev.preventDefault();
if (ev.dataTransfer.items) {
[...ev.dataTransfer.items].forEach((item, i) => {
if (item.kind === "file" && (item.type === "image/png" || item.type === "image/gif" || item.type === "image/jpg" || item.type === "image/jpeg")) {
const file = item.getAsFile();
if(props.sizeLimit && file.size > props.sizeLimit)
{
setStatusMessage("File is too large.");
}
else
{
setFileName(file.name);
getBase64(file);
}
}
else
{
setStatusMessage("Invalid file type.");
}
});
}
}
While not the cleanest code, it allows me to easily set up which file types I will accept. I also check for the file size against an optional prop of fileSize that can be passed by the parent.
The last important thing to note here is my getBase64 function, which converts the input file into a base64 string:
const getBase64 = (file: any) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function () {
setFileInput(reader.result);
};
reader.onerror = function (error) {
console.log('Error: ', error);
};
return reader.result;
}
Here I use a FileReader to parse the file into a string, and store the result in my fileInput state.
For the image cropping functionality, I already mentioned I used react-cropper as it already contains everything I need. I can simply put it into a dialog
element and use showModal()
once the user uploads an image:
<dialog ref={dialogRef} id="editor">
<div id={props.round ? "round" : "rect"}>
<Cropper
src={fileInput}
style={{height: 500, width: 500}}
initialAspectRatio={props.aspect}
aspectRatio={props.aspect}
guides={false}
ref={cropperRef}
/>
</div>
<div id="editor-button-row">
<button id="crop-button" onClick={onCrop}>Crop</button>
</div>
</dialog>
This gives me the following look, in this instance with the prop of "round" passed in for a circular photo:
The user can choose the cropped part of the image they want and react-cropper will handle the task of outputting a base64 string of the resulting image. I then save it to my croppedImage state object and close the dialog modal:
const onCrop = () => {
const cropper = cropperRef.current?.cropper;
setCroppedImage(cropper.getCroppedCanvas().toDataURL());
dialogRef.current?.close();
};
The last component is to display the cropped image to the user. This allows them to confirm the look of the image before uploading it. If not, they can clear the image and try again, or edit it to re-crop:
<div id="img-display">
<div id="clear-button" onClick={() => {clearFileInput()}}>𐌢</div>
<img id={props.round ? "round" : ""} src={croppedImage} />
<div id="options-row">
<button id="edit-button" onClick={showEditor}>Edit</button>
<button id="save-button" onClick={() => saveImage()}>Save</button>
</div>
</div>
Which gives the following (again, with the option of a round image being chosen):
The only other things to add are some simple styling options, such as allowing the user to choose the primary color so they can match it to the rest of their application. For that, you can check out the full code repository here:
https://github.com/Sammortinger/React-ImageUpload
Thanks for joining me on my journey, and let me know if you have any questions or feedback on improvements I can make!
Top comments (0)