Turbo Frames 101: explained with a Mona Lisa analogy
Turbo frames are like a curator replacing pictures inside frames at a museum
Goal: Learn how to wrap any slice of a Rails view in a self‑updating “window”, so you can click links, submit forms, or lazy‑load content without a page refresh—all while writing zero custom JavaScript.
1. What is a Turbo Frame?
Think of the page as a wall and a <turbo-frame>
as a picture frame hanging on it. Whatever is inside that frame can be swapped out for a new picture without repainting the whole wall.
It’s a regular HTML tag:
<turbo-frame>
.The browser, via the Turbo library, watches that frame. When you click a link targeted at the frame, it sneaks off to the server, fetches fresh HTML for the same frame, and swaps it in—faster than a full navigation.
If JavaScript is disabled, it simply falls back to normal links & forms (full‑page reloads). No harm done.
Key rule: The frame you send back from the controller must have the same
id
as the one already on the page. That’s how Turbo knows what to replace.
1.1 The Picture‑Frame Analogy (3 moving parts)
If any one of these three parts is missing—or their id
s don’t line up—the swap won’t happen.
2. Where Do I Define Frames?
Right where you write any other Rails view code—*.html.erb
, slim
, haml
, etc. It’s just markup you commit alongside the rest of the template.
<!-- app/views/posts/show.html.erb -->
<turbo-frame id="post_<%= @post.id %>">
…content you expect to change goes here…
</turbo-frame>
There’s no separate “frame file”. You can nest frames, render them from partials, or even stream them via WebSockets later—same tag.
2.1 Why Unique Frame IDs Matter
Rule of thumb: If the HTML you render can appear more than once on a page (e.g., list items, modals, nested comments), bake the database id or a GUID into the frame id. One‑off components may use a simple id like
main
ordetail
, but plan ahead for reuse.
Example 1: Inline Post Editing
Starting point (classic Rails)
<!-- show.html.erb -->
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>
<%= link_to "Edit title", edit_post_path(@post) %>
Clicking Edit goes to /posts/42/edit
, the whole page reloads.
Add a frame wrapper
<!-- show.html.erb -->
<turbo-frame id="post_<%= @post.id %>">
<h1><%= @post.title %></h1>
<%= link_to "Edit title", edit_post_path(@post),
data: { turbo_frame: "post_#{@post.id}" } %>
</turbo-frame>
We’ve told Turbo: “When this link is clicked, fetch the new HTML but keep it inside this frame.”
Render the same frame from the controller
# posts_controller.rb
def edit
render partial: "posts/title_form", locals: { post: @post }
end
And the partial:
<!-- _title_form.html.erb -->
<turbo-frame id="post_<%= post.id %>">
<%= form_with(model: post, url: post_path(post), method: :patch,
data: { turbo_frame: "post_#{post.id}" }) do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<% end %>
</turbo-frame>
Now:
User clicks Edit. Turbo intercepts and performs an Ajax GET to
/posts/42/edit
.Server responds with the
_title_form
partial wrapped in the sameid
.Turbo swaps the current frame contents with the new form—the rest of the page never budges.
User submits. Controller
update
action re-renders another partial containing the fresh heading. Turbo swaps again. Voilà!
No custom JS, no Turbo Stream broadcasts—just HTML.
Escaping a Frame with target="_top"
Sometimes a link inside a frame needs to navigate the whole page, not just the frame—e.g., a "View full profile" button inside a small profile card. Add target="_top"
(or the Rails helper data: { turbo_frame: "_top" }
) to that link or form:
<%= link_to "View full post", post_path(post), data: { turbo_frame: "_top" } %>
What happens? Turbo treats
_top
as a special keyword meaning “bubble this navigation out to the outermost browsing context.” In other words, perform a normal full‑page visit.Use it when: a modal should close and redirect; a frame-stuck link needs to break free; or any time in‑frame navigation would be confusing.
Example 2. Master–Detail Layout (Two‑Panel)
Imagine an email client UI: folders on the left, message body on the right. In Rails terms you have an index list and a show pane living side‑by‑side.
Mark‑up Skeleton
<!-- app/views/resources/index.html.erb -->
<div class="two‑col">
<aside class="list">
<% @resources.each do |res| %>
<%= link_to res.name,
resource_path(res),
data: { turbo_frame: "detail" }, # <‑‑ key!
class: (res == @resource ? "active" : "") %><br>
<% end %>
</aside>
<section class="detail">
<!-- Right‑hand pane starts blank or with first record -->
<turbo-frame id="detail">
<%= render "placeholder" %>
</turbo-frame>
</section>
</div>
Left panel: ordinary links, but each carries
data-turbo-frame="detail"
so Turbo knows to send the GET response into that frame.Right panel: a single, fixed frame with id
detail
. It will be swapped each time.
Controller Show Action
# resources_controller.rb
def show
respond_to do |format|
# For normal full‑page visits (JS off) render layout as usual
format.html
# When Turbo asks just for the frame, send only the inner partial
format.turbo_stream do
render partial: "resources/detail", locals: { resource: @resource }
end
end
end
Partial:
<!-- _detail.html.erb -->
<turbo-frame id="detail">
<h2><%= resource.name %></h2>
<p><%= resource.description %></p>
</turbo-frame>
Flow
Page loads with list + empty detail frame.
User clicks a link in the list ➜ Turbo fires GET
/resources/7
withTurbo-Frame: detail
header.Controller responds with just the
_detail
partial wrapped inid="detail"
.Turbo swaps the right‑hand pane. Left list stays put.
Browser history: URL updates to
/resources/7
, so refresh/bookmark works.
Want real‑time updates? Broadcast Turbo Streams to the
detail
frame, or re‑render the list frame withid="list"
too.
Example 3. Modal Dialog (Pop‑Out)
Need a pop‑up form that doesn’t yank you off the current page? Wrap the inside of a <dialog>
(or any absolutely‑positioned div) in a Turbo Frame and target it from the triggering link.
Mark‑up Skeleton
<!-- some_view.html.erb -->
<!-- Trigger button -->
<%= link_to "New Task",
new_task_path,
data: { turbo_frame: "modal" } %>
<!-- Hidden dialog lives anywhere on the page -->
<dialog id="task_modal" class="modal">
<turbo-frame id="modal">
<!-- Empty at first; Turbo will fill me in -->
Loading…
</turbo-frame>
</dialog>
Tiny Stimulus
A tiny Stimulus controller (or vanilla JS) can open the dialog when Turbo finishes loading:
// controllers/modal_controller.js
export default class extends Controller {
connect() {
this.element.showModal();
}
close() {
this.element.close();
}
}
Attach it in the partial below.
Controller Action
# tasks_controller.rb
def new
@task = Task.new
render partial: "tasks/form", locals: { task: @task }
end
Partial Returned Inside the Modal Frame
<!-- _form.html.erb -->
<turbo-frame id="modal" data-controller="modal">
<h2>New Task</h2>
<%= form_with model: task, data: { turbo_frame: "modal" } do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<% end %>
<!-- Close link escapes the frame & closes dialog -->
<%= link_to "Cancel", "#", data: { action: "modal#close", turbo_frame: "_top" } %>
</turbo-frame>
Flow:
User clicks “New Task”. Browser requests
/tasks/new
withTurbo-Frame: modal
.Server responds with
_form
partial wrapped inid="modal"
plus Stimulus controller.Turbo swaps the empty modal frame; Stimulus
connect
auto‑opens<dialog>
.Submit triggers
create
→ controller re‑renders success HTML (could be a Turbo Stream broadcast or_top
redirect). The dialog closes.
You can skip Stimulus if you rely on CSS:
dialog[open] { display: flex; }
—Turbo injects theopen
attribute automatically if the frame was inside the dialog when the response arrives.
What Else Can I Do?
6. Common Pitfalls (and fixes)
Nothing happens when I click. Check dev‑tools network: did the server return a
<turbo-frame>
with the sameid
? If not, Turbo refuses to swap.Redirects break out of the frame. If you want to redirect inside the frame, do nothing—it works. If you need a full‑page redirect, add
turbo_frame_request?
guard in controller andredirect_to …, status: 303
.File uploads / heavy JS widgets. Mark the form:
data-turbo="false"
to opt‑out, or use Stimulus.
6. Quick Mental Checklist Before You Ship
Does the piece of UI you want to update live in one DOM chunk? Wrap it.
Stable, unique
id
? (Conventionalmodel_<id>
works great.)All links/forms that should stay in the frame carry
data: { turbo_frame: id }
(or live inside, which implies it).Controller action renders a view/partial with the same frame id.
JavaScript disabled? Page still works? Good—ship it. 🛳️