Layered Turbo Frame Drawers: A Simple Pattern for Nested Navigation in Rails
When building modern Rails applications with Hotwire, you'll often need drawers (or modals) that can open on top of each other. Think of drilling down through related records: Inventory Item → Parts History → Component Details.
The naive approach of hardcoding drawer-2
, drawer-3
, etc. quickly becomes unmaintainable. Here's a clean, Rails-idiomatic solution that scales.
The Problem
You have a drawer system working perfectly with Turbo Frames:
<!-- Works great for a single drawer -->
<%= link_to "View Details", item_path(@item),
data: { turbo_frame: 'drawer' } %>
<turbo-frame id="drawer"></turbo-frame>
But what happens when you need to open a second drawer from within the first drawer? If you target the same drawer
frame, you replace the parent. If you hardcode drawer-2
, you're locked into exactly two layers.
The Solution: Dynamic Frame Names
The key insight is to let the caller specify the frame name and pass it through the request context.
Step 1: Capture the Frame from Request Headers
When Turbo makes a frame request, it automatically sends a Turbo-Frame
header. Use this in your controller:
class PartsHistoryController < ApplicationController
def show
@turbo_frame = request.headers["Turbo-Frame"]
# Rest of your action...
end
end
Step 2: Update Your Drawer Helper
Make the frame name dynamic:
# app/helpers/application_helper.rb
def drawer(title:, position: "right", turbo_frame: "drawer", &block)
render(
partial: "shared/drawer",
locals: {
title: title,
position: position,
turbo_frame: turbo_frame,
content: capture(&block)
}
)
end
Step 3: Use the Dynamic Frame in Your Drawer Partial
<!-- app/views/shared/_drawer.html.erb -->
<%= turbo_frame_tag turbo_frame do %>
<dialog class="drawer" data-controller="drawer">
<!-- Your drawer content -->
<%= content %>
</dialog>
<% end %>
Step 4: Nest Frames in Parent Drawers
In your parent drawer view, add a turbo-frame tag where the child drawer will render:
<!-- app/views/inventory_line_items/show.html.erb -->
<%= drawer(title: "Stock ##{@item.number}") do %>
<div class="drawer-content">
<!-- Parent drawer content -->
<%= link_to "Show History",
parts_history_path(@item),
data: { turbo_frame: 'parts-history-drawer' },
class: "button" %>
</div>
<!-- Child drawer will render here -->
<%= turbo_frame_tag "parts-history-drawer" %>
<% end %>
Step 5: Use the Frame Context in Child Drawers
The child drawer automatically receives the frame name from the header:
<!-- app/views/parts_history/show.html.erb -->
<%= drawer(
title: "Parts History",
turbo_frame: @turbo_frame # Uses the captured header value
) do %>
<!-- Child drawer content -->
<!-- Can go deeper if needed! -->
<%= turbo_frame_tag "component-details-drawer" %>
<% end %>
How It Works
- User clicks a link with
data: { turbo_frame: 'parts-history-drawer' }
- Turbo sends a request with header
Turbo-Frame: parts-history-drawer
- Controller captures this:
@turbo_frame = request.headers["Turbo-Frame"]
- View renders with the correct frame name
- Content appears in the nested
<turbo-frame id="parts-history-drawer">
tag
Benefits of This Pattern
✅ No hardcoded layer limits - Works for 2, 3, 4+ levels of nesting
✅ Explicit frame names - Easy to debug and understand
✅ Rails-idiomatic - Uses request context naturally
✅ Minimal changes - Works with existing drawer infrastructure
✅ Flexible - Each drawer explicitly declares where its children render
Going Further: Unique Frame Names
For even more flexibility, generate unique frame names:
def drawer(title:, position: "right", turbo_frame: nil, &block)
# Auto-generate if not provided
turbo_frame ||= "drawer-#{SecureRandom.hex(4)}"
render(
partial: "shared/drawer",
locals: { title:, position:, turbo_frame:, content: capture(&block) }
)
end
Now you can nest drawers infinitely without thinking about naming:
<%= link_to "Details", item_path(@item),
data: { turbo_frame: "drawer-#{SecureRandom.hex(4)}" } %>
Alternative: State Machine Approach
For complex drawer hierarchies with history/navigation, consider a Stimulus controller that manages a drawer stack:
// Tracks all open drawers and their relationships
export default class extends Controller {
static values = { depth: Number }
connect() {
this.element.style.zIndex = 1040 + (this.depthValue * 10)
}
}
But for most applications, the simple header-based approach is sufficient and easier to reason about.
Conclusion
Layered Turbo Frames don't require complex JavaScript state management. By leveraging the Turbo-Frame
request header and explicit frame nesting, you get a clean, maintainable pattern that scales naturally with your application's needs.
The key principles:
-
Let the caller specify the target frame via
data-turbo-frame
- Capture the frame name from request headers in your controller
- Pass it through to your view layer
- Nest frames explicitly in parent views where children should appear
This pattern works for drawers, modals, sidebars, or any nested Turbo Frame scenario.
Want to see a working example? Check out this GitHub repo with a complete implementation.
Top comments (0)