This article was originally published on Rails Designer
How would you tackle this feature in a typical Hotwired Rails app: a HTML-element (like this h1) gets editable on click and when focus is removed, the record is updated.
How about I tell you, it is done using just this HTML:
<editable-content url="<%= post_path(@post) %>">
<h1 name="post[title]">
<%= @post.title %>
</h1>
</editable-content>
<div>
<%= simple_format @post.content %>
</div>
Yes! 🤯 It is made possible using a custom element: editable-content. It is part of a little experimentation phase of custom elements I am currently in.
In this article I want to show how you can create such a custom element. As always the code can be found here.
What are custom elements?
But first: what are custom elements? If you are familiar in this place on the web, you have seen them mentioned before. If not: custom elements are part of the Web Components standard. They let you define your own HTML tags with custom behavior. The beauty is that they are just HTML. You can style them with CSS, use them in your templates and they work without any framework. They have lifecycle callbacks like connectedCallback (when the element is added to the page) and disconnectedCallback (when removed) that make them perfect for encapsulating interactive behavior. Similar to Stimulus controllers. An article that does a deep(er) dive into them is scheduled. 🤫
For this inline editing feature, the custom element handles everything: detecting clicks, creating input fields, saving changes and restoring the display. All wrapped in a clean HTML tag.
Setting up the custom element
Start by creating the JavaScript file for the custom element:
// app/javascript/components/editable_content.js
class EditableContent extends HTMLElement {
connectedCallback() {
this.addEventListener("click", (event) => this.#edit(event));
}
}
customElements.define("editable-content", EditableContent);
The connectedCallback runs when the element is added to the page. It sets up a click listener that triggers the editing flow. The customElements.define call registers the new tag name with the browser.
Import it in your application JavaScript:
import "@hotwired/turbo-rails"
import "controllers"
+
+import "components/editable_content"
And configure importmap to find the new directory:
pin_all_from "app/javascript/controllers", under: "controllers"
+
+pin_all_from "app/javascript/components", under: "components"
Now you can use <editable-content> tags in your views. 🥳
Making elements editable
The #edit method handles the click event and determines what should become editable:
#edit(event) {
const target = this.#editable(event.target);
if (!target || target === this || !target.hasAttribute("name")) return;
const field = this.#create(target);
const wrapper = this.#wrap(field);
wrapper.original = target;
target.replaceWith(wrapper);
field.focus();
field.addEventListener("blur", () => this.#save(wrapper, field));
}
First it finds the actual editable element using #editable. This method walks up the DOM tree until it finds a direct child of the custom element. This means you can click anywhere inside an element (like in the middle of a heading) and the whole element becomes editable.
The method checks for a name attribute. This is required because it tells the server which field to update. Without it, the element is not editable.
Then it creates an input field, wraps it and replaces the original element. The wrapper stores a reference to the original element so it can be restored later. Finally it focuses the field and sets up a blur listener to save changes when the user clicks away.
Creating the input field
The #create method builds the appropriate input element:
#create(element) {
const field = document.createElement(this.#isMultiline(element) ? "textarea" : "input");
if (!this.#isMultiline(element)) field.type = "text";
field.className = element.className;
field.value = element.textContent.trim();
field.name = element.getAttribute("name");
field.addEventListener("click", (event) => event.stopPropagation());
return field;
}
It decides between a textarea and input based on the element type. Block-level elements like paragraphs get textareas. Inline elements like headings get text inputs. The field inherits the original element's classes so your CSS styling carries over. The click listener prevents the field from triggering another edit when clicked.
The #isMultiline helper checks the tag name:
#isMultiline(element) {
return ["p", "div", "blockquote", "pre", "article", "section"].includes(element.tagName.toLowerCase());
}
Saving changes
When the field loses focus, the #save method sends the update to the server:
async #save(wrapper, field) {
const formData = new FormData();
formData.append(field.name, field.value);
const response = await fetch(this.#url, {
method: "PATCH",
headers: { "X-CSRF-Token": this.#csrfToken },
body: formData
});
if (!response.ok) return;
const display = wrapper.original;
display.textContent = field.value;
wrapper.replaceWith(display);
}
It builds a FormData object with the field name and value (to matches Rails' parameter format). The fetch request includes the CSRF token for security. If the save succeeds, it updates the original element with the new value and swaps it back in place.
Limitations and extensions
This implementation keeps things simple. It only handles text content without new lines. For rich text you would need a different approach. But for titles, labels and short descriptions it works great.
You can make fields more clearly editable with CSS. Add a hover effect or an edit icon to signal interactivity. For feedback on successful saves, you could add flash notifications or a subtle CSS animation on the field. The custom element makes it easy to extend with these features.
The beauty of custom elements is that they are just HTML. You can nest them, style them and combine them with other components. They work with Turbo, Stimulus and any other JavaScript you have. And because they use standard browser APIs, they are fast and reliable.
How do you like this approach?

Top comments (0)