DEV Community

Gabriel Martinez
Gabriel Martinez

Posted on

Layered, Dynamic Turbo Frame Drawers

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. User clicks a link with data: { turbo_frame: 'parts-history-drawer' }
  2. Turbo sends a request with header Turbo-Frame: parts-history-drawer
  3. Controller captures this: @turbo_frame = request.headers["Turbo-Frame"]
  4. View renders with the correct frame name
  5. 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
Enter fullscreen mode Exit fullscreen mode

Now you can nest drawers infinitely without thinking about naming:

<%= link_to "Details", item_path(@item), 
    data: { turbo_frame: "drawer-#{SecureRandom.hex(4)}" } %>
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)