apocalypse

dev

Creating drag and drop image upload in React

#Introduction

In this tutorial I want to cover the basics of creating your own image file upload input in React. If you're short on time, you can easily use one of thousands existing solutions - most UI libraries and probably quite a few CDN/cloud file storage providers have implemented their own file inputs. I want to start from the ground up and go through the entire process.

Things that I would cover:

  • general rules and guidelines when dealing with file input
  • displaying selected image
  • enabling drag and drop
  • limiting image size and file types
  • accessibility
  • uploading the image to the server (using Server Actions with Next.js)
  • making it all not look like ass

#Starting with UI first

That's right. Before we do anything, let's make sure that our DIY image uploader looks at least half decent, so that our eyes don't bleed during development. I think most of us have seen a file uploader at least a few times in our lives, but if you're like me, and whenever you have to design anything, you get creatively paralyzed and uninspired, sites like Dribble or Mobbin will come in handy. Let's find some inspiration.

example

#UI Guidelines

Looking through different designs, there are quite a few things that they all have in common:

  • rectangular area with a big padding or simply lot of empty space inside
  • dashed border around the area to attract attention and indicate interaction
  • big icon in the center, containing one of the following: plus sign, cloud, arrow up, file symbol or some combination of them
  • text under the icon, saying "drop files here"
  • button or colored span saying "choose files" or "browse files"
  • content centered in the area
  • some form of indication whether there is some file already selected
  • "upload" CTA button
  • sometimes some other fancy additions like progress indicator

Using this as a guideline, we can create something that is both eye-pleasing and familiar to our users. My take looks like this:

Drag and drop image hereOr on your computerno file selected...

#Implementation

Now, let's get to implementing this in code. The semantics are pretty easy, we create an input with type="file" and a label connected with the input through htmlFor attribute. Since we only want image files to be accepted, we should also add accept="image/*" attribute. You could also pick only a few allowed file extensions, like accept="image/jpeg, image/png, image/gif".

ImageInput.tsx
function ImageInput() { return ( <> <label htmlFor='main-image-input'>browse files</label> <input id='main-image-input' type='file' accept='image/*' className='hidden' /> </> ); } export default ImageInput;

#Hiding the <input /> element

Notice that I've hidden the input itself. The hidden className in Tailwind is short for display: none. We don't want the default browser file input UI to mess with our design. That doesn't stop us from activating the file browser of your system. We can still do it by clicking the label.

#Adding React(ivity)

Now it's time to make it interactive. We can start with handling the file selection from local file system and then add the Drag-and-Drop functionality on top of that. In the code snippet above you can see that the input is uncontrolled - you can change its value (by selecting file from prompt) but you can't retrieve selected value in React. Making this input controlled is quite easy and works the same way as making any other input controlled - using useState and onChange.

ImageInput.tsx
function ImageInput() { const [image, setImage] = useState<File>(); const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.includes('image')) { // incorrect file type return; } if (file.size > 1024 * 1024 * 2) { // you could extract this expression to a constant // file is greater than 2MB return; } setImage(file); } return ( <> <label htmlFor='main-image-input'>browse files</label> <input id='main-image-input' type='file' accept='image/*' className='hidden' onChange={onChange} /> </> ) }

Notice that we take the first item off of e.target.files array. Why is it array? The answer is probably obvious - you can potentially choose multiple files in the type="file" input. You do it by adding multiple boolean prop. We won't do it now, but it is an option to consider.

We also have to validate at least two things:

  • the type of selected file - despite the accept attribute, user can still select any file they want and if we want to process this file as an image, we have to make sure that its MIME type is actually an image
  • size of an image - we want to avoid processing very large files and allow only images smaller than some arbitrary value, like 2MB. We do it by checking file.size attribute that returns the file size in bytes.

#Preview of selected file

Now, we didn't do anything with the file itself yet. We only updated the state to contain the most recently selected file. How to generate some sort of preview to it? That's another quickie.

ImageInput.tsx
function ImageInput() { const [image, setImage] = useState<File>(); const [imagePreview, setImagePreview] = useState(''); const [imageSize, setImageSize] = useState(0); const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; setImage(file); setImagePreview(URL.createObjectURL(file)); setImageSize(file.size); } return ( <> <label htmlFor='main-image-input'>browse files</label> <input id='main-image-input' type='file' accept='image/*' className='hidden' onChange={onChange} /> {imagePreview ? ( <div> <img src={imagePreview} alt='preview' /> <span>size: {imageSize}B</span> <button type="reset" onClick={() => { setImage(undefined); setImagePreview(''); setImageSize(0); }}>Cancel</button> <button>Submit</button> </div> ) : ( <span>no file selected...</span> )} </> ) }

With this easy addition you can:

  • present selected image to the user, to assure him that he picked the right one, before he submits it; URL.createObjectURL(file) returns a URL string for given file, so that you can reference it in an <img /> element as a source
  • present the file size in bytes (you probably should use some helper function to make it more human-readable, like 22kB instead of 22859B)
  • reset the selected image; I created an arrow function in the JSX that does that, you could also make it a separate function like resetPreview()
  • submit selected image - we are yet to write this logic, but the button would be right there when we will

Obviously you should style those elements, leaving them just like that is not an option. That brings us to the next point.

#Design of image preview

When it comes to the actual design, you have a few options available. Generally, it all depends on the type of image that you want your user to upload. If that's something small, like a thumbnail, you could probably just show a small preview as well. If that's something bigger, like a banner or an image for a gallery, it would be nice to show a preview in full resoultion. If it's something that's important to your user, like their profile picture, you should consider even more extended preview interface with more options, like cropping and image or showing how it will look in their profile after submission.

That's fine, but where to actually place this preview? Should it go under the input? Next to it? Inside of it? Well, wherever you think fits the best ;). When in doubt, find inspiration online. I am a big fan of modals, because they allow you to add an extra screen within the same page, without coliding with your existing design. Image preview seems like a good use case for a modal, so that's another option to consider.

I went with showing the preview within the upload section itself, because in my case, I was designing uploader for a thumbnail. Extending my previous design, we end up with something like this (it's interactive, so to see the preview you should actually pick an image from your hard drive):

Drag and drop image hereOr on your computerno file selected...

#Drag and Drop

Now it's time to add the drag and drop functionality. I've seen many many examples of drag and drop implementation with react-dropzone package, but we're not gonna use it. While it's really popular and almost standardized, I think it's good to try and implement something yourself, before wondering what library should you use. Any dependency to your project is a risk - it may get outdated, the maintainer may abandon it or - most often - just bloat your build for no good reason. Drag and Drop is suprisingly easy to implement yourself, as I'm about to show you.

We probably want the user to drag and drop an image on the whole container, so we need to add two event handlers to the container element in order for it to work:

  1. onDrop handler that will fire - no suprise - after we drop any content on the container. It can be selected text, file from the file explorer or even some other (draggable) HTML element. Our handler need to contain logic that will prevent default behavior - for any file dropped on the website it will be opening this file in a new tab (or in the same tab for Firefox) - and also make sure the dropped content is an image.

  2. onDragOver handler, where we also need to prevent the default behavior. This event will fire in a continuous manner (many events will fire one by one) whenever we drag some element over the container. The default behavior of dragging over an element is simply ignoring/not allowing any action to occur. If we didn't prevent default on this event, the drop event will get ignored.

The smallest working snippet for this logic will look something like this:

ImageInput.tsx
function ImageInput() { const [image, setImage] = useState<File>(); const onDrop = (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (!file) { // user dropped something that isn't a file return; } if (!file.type.includes('image')) { // user dropped a file that isn't an image return; } setImage(file); }; return ( <div onDrop={onDrop} onDragOver={(e) => e.preventDefault()} > <label htmlFor='main-image-input'>browse files</label> <input id='main-image-input' type='file' accept='image/*' className='hidden' onChange={onChange} /> </div> ) }

We could also show some error message or animation when the dragged content is invalid. I'll leave you to it.

#Indicating the drag

Now, that's the ugly part. Unfortunatelly there's no :drag pseudoselector in CSS (yet). There is something like :-moz-drag-over in Firefox, but apart from looking like a weird emoticon, it's not really serving any useful purpose. Because of that, you're left with creating new state and attaching a few more event handlers to the main container. Then you can add some animation or maybe just a different background color to indicate to the user that uploader element is activated.

ImageInput.tsx
function ImageInput() { const [dragOver, setDragOver] = useState(false); const onDrop = (e: React.DragEvent<HTMLDivElement>) => { ... setDragOver(false); ... }; const handleDragLeave = (e: React.DragEvent) => { if (e.currentTarget.contains(e.relatedTarget as Element)) { return; } setDragOver(false); }; return ( <div onDrop={onDrop} onDragOver={(e) => e.preventDefault()} onDragEnter={() => setDragOver(true)} onDragLeave={handleDragLeave} > <Icon className={dragOver ? 'animate-bounce' : ''} /> <input /> </div> ); }

There is one tricky part with handling the onDragLeave event. If you have some nested elements inside your main container, dragging your content over them will trigger the onDragLeave handler, because we will technically leave our main container and start dragging over another element. We can't just setDragOver(false) in this situation, because that will cause the indication to switch off in the middle of a container.

To fix this issue, we can check two fields from the event object:

  • e.currentTarget is the element that the event is attached to (the main container).
  • e.relatedTarget is the element that we entered, as we left the main container

Using Element.contains(Element) method we can verify if we are still dragging inside the main container and if so, don't change the dragOver state.

The final result should look and work like this. Again, as it's an interactive component, you can try and drag some picture over it.

Drag and drop image hereOr on your computerno file selected...

#Submitting the image

Now it's time for the most important action in the file uploader - the upload itself. The bulk of this action is left on the server side, where you have to retrieve the image, validate it, possibly compress or resize and send to the storage like CDN or file bucket. There are infinite posibilities when it comes to storing an image. This would be a good moment to insert a sponsored advertisement, so if you're interested in one, hit me up ;). Since this post is not sponsored (yet), I won't go into much details about the very final phase of image submission.

Let's talk about how you can send the image to the server. You can attach the file to an HTTP request and it would be send just right, so it's only a matter of "how".

Related tags:

image
upload
drag
and
drop
dnd
file
form
input
server
actions
cdn
storage
picture
next
react
dropzone