Web Development

Creating Tabbed Interfaces with Hotwire Turbo in Rails

Tabbed interfaces are a common UI pattern for organizing content, but traditional implementations often require full page reloads or heavy JavaScript dependencies. With Hotwire Turbo and Stimulus.js, which come built-in with Rails 7, we can create...

5 minute read

Tabbed interfaces are a common UI pattern for organizing content, but traditional implementations often require full page reloads or heavy JavaScript dependencies. With Hotwire Turbo and Stimulus.js, which come built-in with Rails 7, we can create a seamless tabbed experience with minimal JavaScript and no page refreshes.

In this post, I'll show you how to build beautiful tabbed interfaces with Turbo Frames that maintain their state, support pagination within tabs, and work smoothly with Flowbite's styling.

The Challenge with Traditional Tabs

Traditional tabbed interfaces in Rails typically have one of these issues:

  • Full page reloads: When clicking a tab, the page refreshes, causing a jarring experience
  • Heavy JavaScript: Complex JavaScript libraries that can be difficult to maintain
  • State management challenges: Maintaining tab state across page reloads or nested resources

Hotwire Turbo solves these issues by allowing us to update just the parts of the page that change, without relying on complex JavaScript.

The Solution: Turbo Frames + Stimulus

We'll build a tabbed interface for an Estate management application that:

  • Uses Turbo Frames to swap content without page refreshes
  • Uses Stimulus.js for minimal JavaScript to handle tab styling
  • Maintains consistent styling with Flowbite's utility classes
  • Supports pagination within each tab

Setting Up Our Controller

# app/controllers/estates_controller.rb
class EstatesController < ApplicationController
  include Pagy::Backend

  def show
    @estate = Estate.find(params[:id])

    # Handle different tab requests
    @active_tab = params[:tab] || "dashboard"

    case @active_tab
    when "transactions"
      @pagy, @transactions = pagy(@estate.all_transactions, items: 10)
    when "blocks"
      @pagy, @blocks = pagy(@estate.blocks, items: 10)
    when "properties"
      @pagy, @properties = pagy(@estate.properties, items: 10)
    when "accounts"
      @pagy, @accounting_periods = pagy(@estate.accounting_periods, items: 10)
    when "expenses"
      @pagy, @expense_categories = pagy(@estate.expense_categories, items: 10)
    end

    respond_to do |format|
      format.html
      format.turbo_stream
    end
  end
end

The key elements here:

  • We determine the active tab from the URL parameter or default to dashboard
  • We prepare paginated data only for the active tab to minimize database queries
  • We respond to both HTML and Turbo Stream requests

Creating the Stimulus Controller

// app/javascript/controllers/tabs_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["tab"]

  connect() {
    this.activateCurrentTab()
  }

  activateTab(event) {
    const clickedTab = event.currentTarget
    this.updateTabStyles(clickedTab.dataset.tab)
  }

  activateCurrentTab() {
    const urlParams = new URLSearchParams(window.location.search)
    const currentTab = urlParams.get('tab') || 'dashboard'
    this.updateTabStyles(currentTab)
  }

  updateTabStyles(activeTabName) {
    this.tabTargets.forEach(tab => {
      const isActive = tab.dataset.tab === activeTabName

      // Remove all styling classes
      tab.classList.remove(
        'text-blue-600', 'hover:text-blue-600', 'dark:text-blue-500', 
        'dark:hover:text-blue-500', 'border-blue-600', 'dark:border-blue-500',
        'dark:border-transparent', 'text-gray-500', 'hover:text-gray-600', 
        'dark:text-gray-400', 'border-gray-100', 'hover:border-gray-300', 
        'dark:border-gray-700', 'dark:hover:text-gray-300'
      )

      // Add appropriate classes based on active state
      if (isActive) {
        tab.classList.add(
          'text-blue-600', 'hover:text-blue-600', 'dark:text-blue-500', 
          'dark:hover:text-blue-500', 'border-blue-600', 'dark:border-blue-500'
        )
      } else {
        tab.classList.add(
          'dark:border-transparent', 'text-gray-500', 'hover:text-gray-600', 
          'dark:text-gray-400', 'border-gray-100', 'hover:border-gray-300', 
          'dark:border-gray-700', 'dark:hover:text-gray-300'
        )
      }
    })
  }
}

This controller:

  • Identifies tabs using the tab target
  • Updates tab styling when clicked
  • Reads the current tab from the URL on page load
  • Applies Flowbite's styling classes to active/inactive tabs

The Main View Template

Now let's create our main view with the tabs:

<%# app/views/estates/show.html.erb %>
<div class="px-4 pt-4">
  <%# Breadcrumb navigation %>
  <nav class="mb-4 flex" aria-label="Breadcrumb">
    <ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse">
      <li class="inline-flex items-center">
        <a href="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white">
          <svg class="me-2.5 h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
            <path fill-rule="evenodd" d="M11.3 3.3a1 1 0 0 1 1.4 0l6 6 2 2a1 1 0 0 1-1.4 1.4l-.3-.3V19a2 2 0 0 1-2 2h-3a1 1 0 0 1-1-1v-3h-2v3c0 .6-.4 1-1 1H7a2 2 0 0 1-2-2v-6.6l-.3.3a1 1 0 0 1-1.4-1.4l2-2 6-6Z" clip-rule="evenodd"/>
          </svg>
          Home
        </a>
      </li>
      <li>
        <div class="flex items-center">
          <svg class="mx-1 h-4 w-4 text-gray-400 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 5 7 7-7 7"/>
          </svg>
          <a href="#" class="ms-1 text-sm font-medium text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white md:ms-2">Estates</a>
        </div>
      </li>
      <li aria-current="page">
        <div class="flex items-center">
          <svg class="mx-1 h-4 w-4 text-gray-400 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 5 7 7-7 7"/>
          </svg>
          <span class="ms-1 text-sm font-medium text-gray-500 dark:text-gray-400 md:ms-2"><%= @estate.name %></span>
        </div>
      </li>
    </ol>
  </nav>
  <h1 class="mb-4 text-2xl font-semibold text-gray-900 dark:text-white"><%= @estate.name %></h1>
</div>

<%# Tab Navigation %>
<div class="mb-4 border-b border-gray-200 dark:border-gray-700" data-controller="tabs">
  <ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
    <li class="me-2">
      <%= link_to "Dashboard", estate_path(@estate, tab: "dashboard"), 
          class: "inline-block p-4 border-b-2 rounded-t-lg", 
          data: { 
            turbo_frame: "estate_tab_content", 
            tab: "dashboard",
            tabs_target: "tab",
            action: "click->tabs#activateTab" 
          } %>
    </li>
    <li class="me-2">
      <%= link_to "Accounts", estate_path(@estate, tab: "accounts"), 
          class: "inline-block p-4 border-b-2 rounded-t-lg", 
          data: { 
            turbo_frame: "estate_tab_content", 
            tab: "accounts",
            tabs_target: "tab",
            action: "click->tabs#activateTab" 
          } %>
    </li>
    <li class="me-2">
      <%= link_to "Transactions", estate_path(@estate, tab: "transactions"), 
          class: "inline-block p-4 border-b-2 rounded-t-lg", 
          data: { 
            turbo_frame: "estate_tab_content", 
            tab: "transactions",
            tabs_target: "tab",
            action: "click->tabs#activateTab" 
          } %>
    </li>
    <li class="me-2">
      <%= link_to "Blocks", estate_path(@estate, tab: "blocks"), 
          class: "inline-block p-4 border-b-2 rounded-t-lg", 
          data: { 
            turbo_frame: "estate_tab_content", 
            tab: "blocks",
            tabs_target: "tab",
            action: "click->tabs#activateTab" 
          } %>
    </li>
    <li class="me-2">
      <%= link_to "Properties", estate_path(@estate, tab: "properties"), 
          class: "inline-block p-4 border-b-2 rounded-t-lg", 
          data: { 
            turbo_frame: "estate_tab_content", 
            tab: "properties",
            tabs_target: "tab",
            action: "click->tabs#activateTab" 
          } %>
    </li>
    <li class="me-2">
      <%= link_to "Expense Categories", estate_path(@estate, tab: "expenses"), 
          class: "inline-block p-4 border-b-2 rounded-t-lg", 
          data: { 
            turbo_frame: "estate_tab_content", 
            tab: "expenses",
            tabs_target: "tab",
            action: "click->tabs#activateTab" 
          } %>
    </li>
  </ul>
</div>

<%# Tab Content %>
<%= turbo_frame_tag "estate_tab_content" do %>
  <div class="px-4 pt-2 rounded-lg bg-gray-50 dark:bg-gray-800">
    <% case @active_tab %>
    <% when "dashboard" %>
      <%= render "main_tab" %>
    <% when "accounts" %>
      <%= turbo_frame_tag "accounts_table" do %>
        <%= render "accounting_tab", accounting_periods: @accounting_periods %>
      <% end %>
    <% when "transactions" %>
      <%= turbo_frame_tag "transactions_table" do %>
        <%= render "transactions_tab", transactions: @transactions %>
      <% end %>
    <% when "blocks" %>
      <%= turbo_frame_tag "blocks_table" do %>
        <%= render "blocks_tab", blocks: @blocks %>
      <% end %>
    <% when "properties" %>
      <%= turbo_frame_tag "properties_table" do %>
        <%= render "properties_tab", properties: @properties %>
      <% end %>
    <% when "expenses" %>
      <%= turbo_frame_tag "expenses_table" do %>
        <%= render "expenses_tab", expense_categories: @expense_categories %>
      <% end %>
    <% end %>
  </div>
<% end %>

Notice the structure:

  • A parent div with the Stimulus controller
  • Tab links with data attributes for Stimulus
  • A Turbo Frame that will be updated when tabs are clicked
  • Nested Turbo Frames for each tab's content (for pagination)

Creating Tab-Specific Templates

Let's look at an example tab template with pagination:

<%# app/views/estates/_transactions_tab.html.erb %>
<div class="mb-4 overflow-x-auto md:mb-6 relative min-h-[590px]">
  <h2 class="mb-4 text-xl font-bold leading-none text-gray-900 dark:text-white">Transactions</h2>
  <table class="w-full text-left text-sm text-gray-500 dark:text-gray-400">
    <thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-gray-800 dark:text-gray-400">
      <tr>
        <th scope="col" class="p-4">
          <div class="flex items-center">
            <input id="checkbox-all" type="checkbox" class="h-4 w-4 rounded-sm border-gray-300 bg-gray-100 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-primary-600"/>
            <label for="checkbox-all" class="sr-only">checkbox</label>
          </div>
        </th>
        <th scope="col" class="px-4 py-3 font-semibold">Reference</th>
        <th scope="col" class="px-4 py-3 font-semibold">Entity</th>
        <th scope="col" class="px-4 py-3 font-semibold">Description</th>
        <th scope="col" class="px-4 py-3 font-semibold">Amount</th>
        <th scope="col" class="px-4 py-3 font-semibold">Date</th>
        <th scope="col" class="px-4 py-3 font-semibold">Status</th>
        <th scope="col" class="px-4 py-3 font-semibold">Actions</th>
      </tr>
    </thead>
    <tbody>
      <%= render @transactions %>

      <%# Placeholder rows for consistent height %>
      <% if @transactions.length < 10 %>
        <% (10 - @transactions.length).times do %>
          <tr class="border-b hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-800 border-gray-200 h-[53px]">
            <td class="w-4 px-4 py-3"></td>
            <td class="whitespace-nowrap px-4 py-3"></td>
            <td class="whitespace-nowrap px-4 py-3"></td>
            <td class="whitespace-nowrap px-4 py-3"></td>
            <td class="whitespace-nowrap px-4 py-3"></td>
            <td class="whitespace-nowrap px-4 py-3"></td>
            <td class="whitespace-nowrap px-4 py-3"></td>
            <td class="px-4 py-3"></td>
          </tr>
        <% end %>
      <% end %>
    </tbody>
  </table>
</div>

<%# Pagination %>
<div class="pagination-container px-4">
  <div class="pagination-info">
    Showing <%= @pagy.from %>-<%= @pagy.to %> of <%= @pagy.count %>
  </div>
  <%== pagy_nav(@pagy, link_extra: 'data-turbo-frame="transactions_table"') %>
</div>

<%# Actions %>
<div class="mt-4 mb-4">
  <%= link_to "Add Transaction", new_transaction_path(estate_id: @estate.id), class: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" %>
</div>

This template:

  • Has a minimum height to prevent layout shifting
  • Includes placeholder rows for a consistent appearance
  • Has pagination that targets the specific tab's Turbo Frame
  • Includes action buttons relevant to the tab

Transaction Partial

For completeness, here's an example transaction partial:

<%# app/views/transactions/_transaction.html.erb %>
<tr class="border-b hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-800 border-gray-200">
  <td class="w-4 px-4 py-3">
    <div class="flex items-center">
      <input id="checkbox-table-<%= transaction.id %>" type="checkbox" class="h-4 w-4 rounded-sm border-gray-300 bg-gray-100 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-primary-600"/>
      <label for="checkbox-table-<%= transaction.id %>" class="sr-only">checkbox</label>
    </div>
  </td>
  <th scope="row" class="whitespace-nowrap px-4 py-3 font-medium text-gray-900 dark:text-white">
    <%= link_to transaction.reference_number, transaction_path(transaction), 
        class: "hover:underline", 
        data: { turbo_frame: "_top" } %>
  </th>
  <td class="whitespace-nowrap px-4 py-3 font-medium text-gray-900 dark:text-white"><%= transaction.transactionable.name %></td>
  <td class="whitespace-nowrap px-4 py-3 font-medium"><%= transaction.description %></td>
  <td class="whitespace-nowrap px-4 py-3 font-medium text-gray-900 dark:text-white"><%= format_money(transaction.amount) %></td>
  <td class="whitespace-nowrap px-4 py-3 font-medium text-gray-900 dark:text-white"><%= l(transaction.transaction_date) %></td>
  <td class="whitespace-nowrap px-4 py-3"><%= status_badge(transaction) %></td>
  <td class="px-4 py-3">
    <button id="transaction-<%= transaction.id %>-dropdown-button" type="button" data-dropdown-toggle="transaction-<%= transaction.id %>-dropdown" class="inline-flex items-center rounded-lg p-1 text-center text-sm font-medium text-gray-500 hover:bg-gray-200 hover:text-gray-900 focus:outline-none dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
      <svg class="h-5 w-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
        <path stroke="currentColor" stroke-linecap="round" stroke-width="4" d="M6 12h0m6 0h0m6 0h0"/>
      </svg>
    </button>
    <div id="transaction-<%= transaction.id %>-dropdown" class="z-10 hidden w-40 divide-y divide-gray-100 rounded-lg bg-white shadow-sm dark:divide-gray-600 dark:bg-gray-700">
      <ul class="p-2 text-sm font-medium text-gray-500 dark:text-gray-400" aria-labelledby="transaction-<%= transaction.id %>-dropdown-button">
        <li>
          <%= link_to transaction_path(transaction), 
                data: { turbo_frame: "_top" },
                class: "inline-flex w-full items-center rounded-md px-3 py-2 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white" do %>
            <svg class="me-1.5 h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
              <path fill-rule="evenodd" d="M5 7.8C6.7 6.3 9.2 5 12 5s5.3 1.3 7 2.8a12.7 12.7 0 0 1 2.7 3.2c.2.2.3.6.3 1s-.1.8-.3 1a2 2 0 0 1-.6 1 12.7 12.7 0 0 1-9.1 5c-2.8 0-5.3-1.3-7-2.8A12.7 12.7 0 0 1 2.3 13c-.2-.2-.3-.6-.3-1s.1-.8.3-1c.1-.4.3-.7.6-1 .5-.7 1.2-1.5 2.1-2.2Zm7 7.2a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd"/>
            </svg>
            Details
          <% end %>
        </li>
        <li>
          <button type="button" class="inline-flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-red-600 hover:bg-gray-100 dark:text-red-500 dark:hover:bg-gray-600">
            <svg class="me-1.5 h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
              <path fill-rule="evenodd" d="M8.6 2.6A2 2 0 0 1 10 2h4a2 2 0 0 1 2 2v2h3a1 1 0 1 1 0 2v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8a1 1 0 0 1 0-2h3V4c0-.5.2-1 .6-1.4ZM10 6h4V4h-4v2Zm1 4a1 1 0 1 0-2 0v8a1 1 0 1 0 2 0v-8Zm4 0a1 1 0 1 0-2 0v8a1 1 0 1 0 2 0v-8Z" clip-rule="evenodd"/>
            </svg>
            Delete
          </button>
        </li>
      </ul>
    </div>
  </td>
</tr>

Note the data-turbo-frame="_top" attribute on the links, which tells Turbo to navigate to a new page rather than trying to load content within the current frame.

Adding Turbo Stream Support

Finally, we need a Turbo Stream template to handle tab switching:

<%# app/views/estates/show.turbo_stream.erb %>
<%= turbo_stream.update "estate_tab_content" do %>
  <div class="px-4 pt-2 rounded-lg bg-gray-50 dark:bg-gray-800">
    <% case @active_tab %>
    <% when "dashboard" %>
      <%= render "main_tab" %>
    <% when "accounts" %>
      <%= turbo_frame_tag "accounts_table" do %>
        <%= render "accounting_tab", accounting_periods: @accounting_periods %>
      <% end %>
    <% when "transactions" %>
      <%= turbo_frame_tag "transactions_table" do %>
        <%= render "transactions_tab", transactions: @transactions %>
      <% end %>
    <% when "blocks" %>
      <%= turbo_frame_tag "blocks_table" do %>
        <%= render "blocks_tab", blocks: @blocks %>
      <% end %>
    <% when "properties" %>
      <%= turbo_frame_tag "properties_table" do %>
        <%= render "properties_tab", properties: @properties %>
      <% end %>
    <% when "expenses" %>
      <%= turbo_frame_tag "expenses_table" do %>
        <%= render "expenses_tab", expense_categories: @expense_categories %>
      <% end %>
    <% end %>
  </div>
<% end %>

This template tells Turbo Stream how to update the page when a tab is clicked.

Conclusion

With this implementation, we've created a seamless tabbed interface that:

  • Provides instant tab switching without page refreshes
  • Maintains proper tab styling through Stimulus.js
  • Supports pagination within tabs for large data sets
  • Preserves consistent layout with placeholder rows
  • Uses Flowbite's styling for a professional look

The beauty of this approach is how little JavaScript we needed to write. The combination of Turbo Frames and Stimulus.js handles all the heavy lifting, resulting in a faster, more maintainable codebase.
This pattern can be extended to any Rails application that needs tabbed interfaces, and the same principles apply to other UI components like accordions, modals, and more.

Key Takeaways

  • Turbo Frames allow us to update specific parts of the page without full refreshes
  • Stimulus.js provides targeted JavaScript functionality where needed
  • Nested Turbo Frames enable pagination within tabs
  • Unique frame IDs prevent conflicts between different views
  • The data-turbo-frame=_top attribute allows links to navigate outside of frames

By leveraging these techniques, you can create dynamic, responsive interfaces that feel like a single-page application but retain the simplicity and maintainability of traditional Rails views.

Subscribe to our newsletter

Join 10,000+ entrepeneurs and get creative site breakdowns, design musings and tips directly into your inbox.