name: reactive-rails-ui description: Build smooth, reactive Rails UIs using Turbo Morphing, the View Transitions API, and Stimulus optimistic UI patterns. Activate when the user is working on a Rails app and wants responsive, SPA-like interactions without client-side state management.
Reactive Rails UI
Three techniques that combine to make standard Rails redirect-based controllers feel as responsive as a SPA — with zero client-side state management:
- Turbo Morphing — diffs the DOM instead of replacing it, preserving scroll position and focus
- View Transitions API — browser-native cross-fade animations between page states
- Stimulus Optimistic UI — instant visual feedback via aria-attribute toggling before the server responds
Together, they let you keep simple CRUD controllers that redirect_to after every mutation — no Turbo Streams, no Turbo Frames (except for inline editing), and no client-side state.
Prerequisites & Setup
- Rails 8+ with Turbo (included by default)
- Stimulus (included by default)
- Tailwind CSS (for
group-aria-*utility variants) - Importmaps or any JS bundler
One-time layout setup
Add the View Transitions meta tag to your application layout:
<%# app/views/layouts/application.html.erb %>
<head>
<!-- ... existing tags ... -->
<meta name="view-transition" content="same-origin">
</head>
This opts the entire application into the View Transitions API for same-origin navigations.
Technique 1: Turbo Morphing
Instead of replacing the entire <body>, Turbo Morphing diffs the old and new DOM and applies only the changes — like a server-side virtual DOM. This preserves scroll position, focus state, and CSS transitions.
View declaration
At the top of any index/listing view:
<%# app/views/resources/index.html.erb %>
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
Controller pattern
All mutation actions (create, update, destroy, and custom actions like toggle) use redirect_to instead of rendering or returning Turbo Streams:
class TodosController < ApplicationController
before_action :set_todo, only: %i[edit update toggle destroy]
def index
@todo = Todo.new
@active_todos = Todo.active.ordered
@completed_todos = Todo.completed.ordered
end
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 update
if @todo.update(todo_params)
redirect_to todos_path
else
render :edit, status: :unprocessable_entity
end
end
def toggle
@todo.update!(completed: !@todo.completed)
redirect_to todos_path
end
def destroy
@todo.destroy!
redirect_to todos_path
end
private
def set_todo
@todo = Todo.find(params[:id])
end
def todo_params
params.require(:todo).permit(:name)
end
end
Routes
resources :todos, except: [:show] do
member do
patch :toggle
end
end
root "todos#index"
Why this works
Because every mutation redirects to the same index action, Turbo Morphing diffs the full page and applies only the changes. No Turbo Streams, no partial re-rendering — just a standard redirect.
Technique 2: View Transitions API
The View Transitions API provides browser-native crossfade animations when DOM elements change position or state. Combined with Turbo Morphing, this creates smooth animations for reordering, appearing, and disappearing elements.
Partial setup
Every record partial MUST:
- Use
dom_id(record)as the element'sid - Set
view-transition-nameto a unique value (usedom_id) - Set
view-transition-classto group elements for shared transition rules
<%# app/views/todos/_todo.html.erb %>
<%= tag.div id: dom_id(todo),
data: {
controller: "toggle-attribute",
toggle_attribute_attribute_value: "aria-checked",
},
aria: { checked: todo.completed? },
class: "group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-gray-50 aria-checked:opacity-60",
style: "view-transition-name: #{dom_id(todo)}; view-transition-class: todo" do %>
<%# ... content ... %>
<% end %>
Key rules
view-transition-nameMUST be unique per element on the page —dom_id(record)guarantees this.view-transition-classgroups elements that share the same transition animation.- The
groupTailwind class on the container enablesgroup-aria-*variants on children.
Optional CSS customization
You can customize transition animations:
::view-transition-group(.todo) {
animation-duration: 0.3s;
}
Technique 3: Stimulus Optimistic UI
The server round-trip takes ~100-300ms. Without optimistic UI, the user sees no feedback until the server responds. The optimistic UI pattern toggles an aria attribute immediately on click, providing instant visual feedback.
Stimulus controller
// app/javascript/controllers/toggle_attribute_controller.js
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())
}
}
This controller is generic — it works with any boolean aria attribute (aria-checked, aria-expanded, aria-selected, aria-pressed, etc.).
HTML wiring
<%= tag.div id: dom_id(todo),
data: {
controller: "toggle-attribute",
toggle_attribute_attribute_value: "aria-checked",
},
aria: { checked: todo.completed? },
class: "group ..." do %>
<%= button_to toggle_todo_path(todo),
method: :patch,
data: { action: "click->toggle-attribute#toggle" },
form: { class: "flex" } do %>
<%# Unchecked state — visible when aria-checked is false %>
<span class="inline-flex group-aria-checked:hidden ...">
<%# empty circle %>
</span>
<%# Checked state — visible when aria-checked is true %>
<span class="hidden group-aria-checked:inline-flex ...">
<%# checkmark %>
</span>
<% end %>
<span class="truncate group-aria-checked:line-through group-aria-checked:text-gray-400">
<%= todo.name %>
</span>
<% end %>
How it works
- User clicks the toggle button
- Stimulus immediately flips
aria-checkedon the container div - Tailwind's
group-aria-checked:*variants instantly update child styling - Meanwhile,
button_tosubmits the form to the server - Server updates the record and redirects back
- Turbo Morphing diffs the page — the attribute now matches the server state
- View Transitions animate any element movement (e.g., todo moving between lists)
Checklist for a New Resource
When adding reactive UI to a new resource, follow these steps:
- Layout: Ensure
<meta name="view-transition" content="same-origin">is in your application layout - Model: Create the model with any boolean/state fields needed for toggling
- Controller: Write a standard CRUD controller where all mutation actions (
create,update,destroy, custom toggles) useredirect_toback to the index - Routes: Add
resourceswith any custommemberroutes for toggle actions - Index view: Add
<% turbo_refreshes_with method: :morph, scroll: :preserve %>at the top - Partial: For each record partial:
- Use
dom_id(record)as the elementid - Set
style: "view-transition-name: #{dom_id(record)}; view-transition-class: <group>" - Add aria attributes reflecting server state (e.g.,
aria: { checked: record.completed? }) - Wire up Stimulus
toggle-attributecontroller withdata-toggle-attribute-attribute-value - Use
groupclass on container andgroup-aria-*variants on children
- Use
- Stimulus: Create
toggle_attribute_controller.jsif it doesn't already exist (it's generic and reusable) - Manifest: Update the Stimulus manifest (
rails stimulus:manifest:update)
Adapting to Other Use Cases
The three techniques are not limited to todo-style toggles. Here are other patterns:
Accordion (expand/collapse)
<%= tag.div data: {
controller: "toggle-attribute",
toggle_attribute_attribute_value: "aria-expanded",
},
aria: { expanded: false },
class: "group" do %>
<button data-action="click->toggle-attribute#toggle">
<span class="group-aria-expanded:rotate-90 transition-transform">▶</span>
Section Title
</button>
<div class="hidden group-aria-expanded:block">
Content here...
</div>
<% end %>
Tabs (selection)
Use aria-selected with the same Stimulus controller. Each tab button toggles its own aria-selected attribute.
Inline editing with Turbo Frames
For inline editing, wrap the editable content in a Turbo Frame:
<%= turbo_frame_tag dom_id(record, :name) do %>
<span><%= record.name %></span>
<%= link_to edit_resource_path(record) %>
<% end %>
The edit view renders inside the frame without a full page navigation.
Common Pitfalls
Missing
dom_id: Every record element MUST haveid: dom_id(record)for Turbo Morphing to correctly diff elements. Without it, Turbo replaces instead of morphing.Duplicate
view-transition-name: Eachview-transition-namemust be unique on the page. Usingdom_id(record)guarantees uniqueness. If you render the same record twice (e.g., in two lists), you'll get broken transitions.Forgotten morph declaration: Without
turbo_refreshes_with method: :morph, scroll: :preserveat the top of the view, Turbo falls back to full-page replacement, losing scroll position and breaking animations.Rendering instead of redirecting: Mutation actions MUST
redirect_tothe index path — notrender. Rendering skips the morph pipeline and breaks the pattern.Stale Stimulus manifest: After creating a new Stimulus controller, run
rails stimulus:manifest:updateor the controller won't be registered. When using the Rails generator (rails generate stimulus toggle_attribute), the manifest is updated automatically.Browser support: The View Transitions API is supported in Chromium-based browsers (Chrome, Edge, Arc, Brave). Firefox and Safari have partial/no support as of early 2025. The UI still works without it — transitions just won't animate.
Missing
groupclass: Thegroup-aria-*Tailwind variants only work when an ancestor has thegroupclass. Make sure the container div (the one with the aria attribute) hasclass="group ...".
Reference
- Smooth UI Animations on Server-Rendered HTML — the blog post describing these techniques
- Intrepidd/rails-hotwire-todo-app — reference implementation
- Turbo Morphing docs — official Turbo page refresh documentation
- MDN View Transitions API — browser API reference
- Stimulus Handbook — Stimulus framework documentation
- Tailwind group-aria variants — Tailwind aria state variants