I’ve been using Hotwire in production since the beta release of Turbo, and it’s changed the way I build web apps. It’s a great way to get SPA-like interactivity at a fraction of the complexity.
In this article, we’ll explore how we can use Turbo 8’s morphing, the view transitions API, and a sprinkle of Stimulus to create a smooth, interactive experience with minimal code. We’ll see how these technologies work together to make server-rendered apps feel modern and snappy.
We’ll build a simple todo app to demonstrate the concept, and enhance it step by step.
The full source code is available on GitHub: Intrepidd/rails-hotwire-todo-app
Quick context
The stack is pretty vanilla Rails 8 with Propshaft, Importmaps, Turbo, and Tailwind. Nothing too fancy.
The controller is as boring as it gets:
# app/controllers/todos_controller.rb
def create
@todo = Todo.new(todo_params)
if @todo.save
redirect_to todos_path
else
redirect_to todos_path, alert: @todo.errors.full_messages.to_sentence
end
end
def toggle
@todo.update!(completed: !@todo.completed)
redirect_to todos_path
end
def destroy
@todo.destroy!
redirect_to todos_path
end
As you can see, there are some issues: the scroll is not preserved, making the app jump back to the top each time we check a todo, and the focus of the text input blinks: the autofocus comes back after the page update.
Smoothly update the page with Turbo morphing
With a single line of code, we can fix both issues:
<%# app/views/todos/index.html.erb %>
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
This tells Turbo: “When you receive new HTML for this page, don’t replace the whole <body>. Instead, diff the new HTML against the current DOM and surgically patch only what changed.”
So when a user creates a new todo or checks an existing one and the controller redirects back to the index, Turbo fetches the new page, Idiomorph (the underlying morphing engine) compares it to the current DOM, and applies only the necessary mutations to update the page. This means that elements that didn’t change (like the text input) keep their state, including focus, and the scroll position is preserved.
View Transitions
This is already pretty great, but we can do better. Right now the page updates instantly to the new state, and it can feel abrupt. We would love to have simple but nice animations, like the todo sliding from Active to Completed.
For a server-rendered app this would be a nightmare to implement manually, but with the View Transitions API, it’s actually really simple to achieve, and Turbo has built-in support for it.
Let’s have a quick crash course on the View Transitions API before we see how it integrates with Turbo.
The basic idea
The core concept is pretty simple. You wrap a DOM update in a document.startViewTransition() call:
document.startViewTransition(() => {
// Mutate the DOM however you want
element.textContent = "New content";
someList.appendChild(newItem);
otherElement.remove();
});
The browser does the rest:
- It takes a screenshot of the current state of the page (the “old” snapshot)
- It runs your callback, which mutates the DOM
- It captures the new state of the page (the “new” snapshot)
- It animates from the old snapshot to the new DOM
By default, you get a nice whole-page crossfade on every DOM mutation. No CSS, no JavaScript animation logic. The browser handles it.
Per-element tracking with view-transition-name
The default crossfade is fine for full-page transitions, but the API gets really interesting when you assign a view-transition-name to individual elements:
<div style="view-transition-name: my-card">Hello</div>
Now the browser tracks that specific element across DOM mutations. If my-card existed in one position before the update and in a different position after, the browser will animate it moving from the old position to the new one. It basically treats it as “the same element” across both states.
This is the nifty part: you don’t write any animation code for the movement. No keyframes, no translateX, no calculating positions. By default, the browser handles everything with a crossfade for elements that appear or disappear, and a smooth translation for elements that move from one position to another. These defaults are honestly pretty good, for most use cases, you don’t need to touch any CSS at all.
That said, if you want to customize the animations, you absolutely can. Target ::view-transition-old(.card) and ::view-transition-new(.card) pseudo-elements to customize the enter/exit animations separately, add custom keyframes, change easing, whatever you need. But the key point is: you don’t have to. The browser defaults are solid, and you only reach for custom CSS when you want to fine-tune things.
Grouping and styling with view-transition-class
You can assign a view-transition-class to group elements and style their transitions together:
<div style="view-transition-name: card-1; view-transition-class: card">...</div>
<div style="view-transition-name: card-2; view-transition-class: card">...</div>
::view-transition-group(.card) {
animation-duration: 0.3s;
}
In this example, we set a 0.3s animation duration for all elements with the card class.
A note on browser support: As of March 2026, view transitions are supported in all major browsers, including Chrome, Edge, Firefox, and Safari.
That’s basically all you need to know. Now that we have that out of the way, let’s see how it integrates with Turbo.
View Transitions + Turbo: a seamless integration
So the View Transitions API is cool, but using it manually means wrapping every DOM update in document.startViewTransition(). In a traditional app, you’d have to call it yourself every time you modify the page.
Turbo handles this for you. All you need to do is add a single <meta> tag to your layout:
<%# app/views/layouts/application.html.erb %>
<meta name="turbo-view-transition" content="true">
If the browser does not support view transitions, this meta tag is simply ignored, and Turbo falls back to its default behavior. No breakage, just a nice progressive enhancement.
(Fun fact: there is interesting history behind this meta tag, and I worked on it! Have a look over there )
That’s it. Turbo now automatically wraps every page navigation and every morph in a document.startViewTransition() call. You never call it yourself. You never write custom JavaScript to trigger it. You just tell Turbo “I want view transitions” and it handles the plumbing.
This is what makes the combo so powerful. Turbo morphing already diffs the DOM and applies the minimal set of mutations. View Transitions just needs someone to wrap those mutations in startViewTransition(). Turbo does exactly that, for free.
Back to the todo app:
After adding the meta tag, we just have to assign a view-transition-name and view-transition-class to each todo item:
<%# app/views/todos/_todo.html.erb %>
<%= tag.div id: dom_id(todo),
style: "view-transition-name: #{dom_id(todo)}; view-transition-class: todo"
# ... do %>
</div>
We now get a nice sliding animation when a todo is toggled, and a smooth fade when a new todo is created or deleted. Just by setting a meta tag and a couple of CSS properties, this feels too good to be true!
Turbo and view transitions go so well together because they have kind of the same philosophy: write the HTML once to describe what the current state should look like, and let the technology handle the transitioning from one state to another. This leads to writing expressive dead simple code, that is so simple to understand yet behaves like a modern SPA, I love it!
Optimistic UI with Stimulus
There’s one more little issue with our todo app. When we check a todo, we have to wait for the server response to see the change. This is inherent to the way Turbo works, but we can work around it with a sprinkle of Stimulus.
We can update the styling of the todo as soon as the user clicks the checkbox. Then, when the server responds, the sliding animation will happen. This is a tiny detail, but it makes the app feel much more responsive.
We can implement a small Stimulus controller to handle it, and I’ll share with you a great tip to help writing generic and simple Stimulus controllers.
The idea is to switch a single class or attribute on the element rather than having a complex controller that toggles multiple classes and knows too much about the styling. This way, the controller is reusable and doesn’t need to be updated if we change the styling of the completed state.
The way I like to do it is using aria-* attributes to represent the state of the element, and use CSS to style based on those attributes. It works especially well with tailwindcss (that I absolutely love), and complements the approach perfectly: our HTML/CSS describe the different states of the element and there is just a minimal attribute or class to toggle to switch between those states.
In our case, we can toggle an aria-checked attribute on the todo item, and use aria-checked:* and group-aria-checked:* variants in Tailwind to style the different states of the todo.
For example, in our todo partial, we have :
<span class="truncate group-aria-checked:line-through group-aria-checked:text-gray-400">
<%= todo.name %>
</span>
and we end up with a generic, reusable and simple Stimulus controller :
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
attribute: String
}
toggle(event) {
const currentValue = this.element.getAttribute(this.attributeValue)
const isTrue = currentValue === "true"
this.element.setAttribute(this.attributeValue, (!isTrue).toString())
}
}
We just plug it on our checkbox like so (stripped some attributes for clarity):
<%= tag.div id: dom_id(todo),
data: {
controller: "toggle-attribute",
toggle_attribute_attribute_value: "aria-checked",
} do %>
<% # ... %>
<%= button_to toggle_todo_path(todo),
data: {
action: "click->toggle-attribute#toggle"
},
do %>
<% # ... %>
<% end %>
<% end %>
This gives us the final result:
Conclusion
The combination of Turbo morphing, View Transitions and Stimulus is a great leap in Rails frontend development. Together, they produce something that genuinely looks and feels like a modern SPA, at a fraction of the cost, while keeping writing the same simple views and controllers we’re used to.
There is a way out of the SPA madness! It’s refreshing to see that browsers are finally catching up with the needs of modern web apps. View transitions are one clear example of that, other features like dialogs, popovers, container queries, web components, etc. are all great examples of how the web platform is evolving to meet the needs of developers without forcing them into complex frameworks.
The source code for the todo app is available here: Intrepidd/rails-hotwire-todo-app
Thanks for reading!