From reactive-rails-ui
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.
npx claudepluginhub lorismaz/rails-claude-code-plugins --plugin reactive-rails-uiThis skill uses the workspace's default tool permissions.
Three techniques that combine to make standard Rails redirect-based controllers feel as responsive as a SPA — with zero client-side state management:
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Automates semantic versioning and release workflow for Claude Code plugins: bumps versions in package.json, marketplace.json, plugin.json; verifies builds; creates git tags, GitHub releases, changelogs.
Three techniques that combine to make standard Rails redirect-based controllers feel as responsive as a SPA — with zero client-side state management:
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.
group-aria-* utility variants)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.
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.
At the top of any index/listing view:
<%# app/views/resources/index.html.erb %>
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
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
resources :todos, except: [:show] do
member do
patch :toggle
end
end
root "todos#index"
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.
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.
Every record partial MUST:
dom_id(record) as the element's idview-transition-name to a unique value (use dom_id)view-transition-class to 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 %>
view-transition-name MUST be unique per element on the page — dom_id(record) guarantees this.view-transition-class groups elements that share the same transition animation.group Tailwind class on the container enables group-aria-* variants on children.You can customize transition animations:
::view-transition-group(.todo) {
animation-duration: 0.3s;
}
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.
// 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.).
<%= 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 %>
aria-checked on the container divgroup-aria-checked:* variants instantly update child stylingbutton_to submits the form to the serverWhen adding reactive UI to a new resource, follow these steps:
<meta name="view-transition" content="same-origin"> is in your application layoutcreate, update, destroy, custom toggles) use redirect_to back to the indexresources with any custom member routes for toggle actions<% turbo_refreshes_with method: :morph, scroll: :preserve %> at the topdom_id(record) as the element idstyle: "view-transition-name: #{dom_id(record)}; view-transition-class: <group>"aria: { checked: record.completed? })toggle-attribute controller with data-toggle-attribute-attribute-valuegroup class on container and group-aria-* variants on childrentoggle_attribute_controller.js if it doesn't already exist (it's generic and reusable)rails stimulus:manifest:update)The three techniques are not limited to todo-style toggles. Here are other patterns:
<%= 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 %>
Use aria-selected with the same Stimulus controller. Each tab button toggles its own aria-selected attribute.
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.
Missing dom_id: Every record element MUST have id: dom_id(record) for Turbo Morphing to correctly diff elements. Without it, Turbo replaces instead of morphing.
Duplicate view-transition-name: Each view-transition-name must be unique on the page. Using dom_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: :preserve at 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_to the index path — not render. Rendering skips the morph pipeline and breaks the pattern.
Stale Stimulus manifest: After creating a new Stimulus controller, run rails stimulus:manifest:update or 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 group class: The group-aria-* Tailwind variants only work when an ancestor has the group class. Make sure the container div (the one with the aria attribute) has class="group ...".