Reusable drag-and-drop image preview in Rails
Custom elements have been covered here berfore. If you have used Hotwire in Rails, you have already used them. Both <turbo-frame> and <turbo-stream> are custom elements. They are just HTML tags with JavaScript behavior attached.
This article walks through building a drag-and-drop image upload custom element that works great in Rails forms. Starting with a simple avatar and ending with a reusable component that handles both inline and external forms. The code is, as usual, available on GitHub.
So first, why not use a regular file input or a Stimulus controller? The answer is that custom elements are perfect for self-contained components. They work anywhere in your HTML without needing to wire up data attributes or controller targets. Drop the tag in your view and it works. No configuration. No boilerplate.
With Stimulus you write:
<div data-controller="image-upload">
<input type="file" data-image-upload-target="input" />
<button data-action="click->image-upload#remove">Remove</button>
</div>
With the custom element I want to explore today, you write:
<image-upload name="user[avatar]" data-preview-image-url="...">
<button type="button" data-remove-image>Remove</button>
</image-upload>
The component creates its own file input. It finds the form automatically. It handles drag-and-drop, preview rendering and removal. All wrapped in a clean, semantic HTML tag.
Lets get started, by creating the custom element in app/javascript/components/image-uploads.js:
class ImageUpload extends HTMLElement {
#img = null;
#input = null;
connectedCallback() {
this.#img = this.#image();
this.#input = this.#fileInput();
this.#load(this.#img);
this.#dragAndDropListeners();
this.#removeButtonListener();
this.#clickToSelectListener();
}
}
customElements.define('image-upload', ImageUpload);
The connectedCallback runs when the element is added to the page. It sets up the image element, creates the file input, loads any existing image and wires up all the event listeners.
The component creates an <img> tag if one does not already exist (only if the data-preview-image-url attribute is present):
#image() {
if (!this.hasAttribute('data-preview-image-url')) return null;
const img = document.createElement('img');
this.insertBefore(img, this.firstChild);
return img;
}
This means you do not need to add an <img> tag to your HTML. If you want to show an existing image, just add the data-preview-image-url attribute with the image URL.
Now it gets interesting. The component creates a hidden file input and appends it to the form, not to the component itself:
#fileInput() {
const name = this.getAttribute('name');
if (!name) {
console.error('image-upload requires a "name" attribute');
return null;
}
const input = document.createElement('input');
input.type = 'file';
input.name = name;
input.accept = 'image/*';
input.style.display = 'none';
const formId = this.getAttribute('form');
const form = formId ? document.getElementById(formId) : document.querySelector('form');
if (form) {
form.appendChild(input);
} else {
this.appendChild(input);
}
input.addEventListener('change', event => this.#fileSelected(event));
return input;
}
This is crucial. The file input must be part of the form for Rails to receive it in the params. The component looks for a form in two ways: if a form attribute is present, it uses that ID to find the form. Otherwise, it finds the first form in the DOM. Use it inside a form (it finds the form automatically) or outside a form (pass the form ID via the form attribute).
When a file is selected (either via click or drag-and-drop), the component renders a preview:
#fileSelected(event) {
const imageFile = event.target.files[0];
if (imageFile) this.#process(imageFile);
}
#process(file) {
if (this.#img) this.#render(file);
}
#render(file) {
const reader = new FileReader();
reader.onload = (event) => {
this.#img.src = event.target.result;
};
reader.readAsDataURL(file);
}
The FileReader converts the file to a data URL which is displayed in the image element. The user sees the preview immediately.
The component listens for drag-and-drop events and adds a visual indicator:
#dragAndDropListeners() {
this.addEventListener('dragover', event => this.#dragOver(event));
this.addEventListener('dragleave', event => this.#dragLeave(event));
this.addEventListener('drop', event => this.#drop(event));
}
#dragOver = (event) => {
event.preventDefault();
event.stopPropagation();
this.setAttribute('data-drag-active', '');
}
#dragLeave = (event) => {
event.preventDefault();
event.stopPropagation();
this.removeAttribute('data-drag-active');
}
#drop = (event) => {
event.preventDefault();
event.stopPropagation();
this.removeAttribute('data-drag-active');
const files = event.dataTransfer.files;
const imageFile = Array.from(files).find(file => file.type.startsWith('image/'));
if (imageFile) this.#process(imageFile);
}
When the user drags a file over the component, it sets a data-drag-active attribute. Style this with CSS to show visual feedback like a blue border or background color.
The component finds a button with data-remove-image and wires it up:
#removeButtonListener() {
const removeButton = this.querySelector('[data-remove-image]');
if (removeButton) removeButton.addEventListener('click', () => this.#remove());
}
#remove() {
this.#input.value = '';
if (this.#img) this.#img.removeAttribute('src');
}
When clicked, it clears the file input and removes the image preview. For has_one_attached in Rails, submitting an empty file input removes the attachment.
Product-minded Rails notes
Monthly roundup on what I am building, open source work and recent articles like this one.
Using the component
Here is the avatar example inside a form:
<%= form_with(model: user) do |form| %>
<div>
<%= form.label :avatar, style: "display: block" %>
<image-upload name="user[avatar]" data-preview-image-url="<%= url_for(@user.avatar) if @user.avatar.attached? %>">
<button type="button" data-remove-image>Remove</button>
</image-upload>
</div>
<div>
<%= form.label :email_address, style: "display: block" %>
<%= form.text_field :email_address %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
And here is the header example outside the form:
<image-upload name="user[header]" form="user_form" data-preview-image-url="<%= url_for(@user.header) if @user.header.attached? %>">
<button type="button" data-remove-image>Remove</button>
</image-upload>
<%= form_with(model: user, id: "user_form") do |form| %>
<!-- form fields -->
<% end %>
The form="user_form" attribute associates the component with the form by ID. The file input gets appended to that form and submits with it.
Custom elements work anywhere in your HTML without needing to wire up data attributes or controller targets. I’ve been enjoying custom elements more and more and for really contained examples like this one, or for static sites built with Perron I think they are wonderful tool to have.
Want to read me more?
-
Preview an Image Before Upload with Hotwire/Stimulus
Learn about JavaScript's FileReader interface to preview user images before they are upload with Hotwire/Stimulus. -
Drag & Drop Images with Preview using Stimulus Outlets
Explore an easy way to drag & drop images using Stimulus while using the Outlets API to preview the dropped image. -
ActiveStorage Direct Upload with Stimulus
ActiveStorage's DirectUpload feature allows files to be directly uploaded to your Cloud's storage without touching your app's server.
Over to you…
What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!
Comments are powered by Chirp Form
{{comment}}