turbo & hotwire patterns

Turbo & Hotwire Patterns Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "turbo & hotwire patterns" with this command: npx skills add kaakati/rails-enterprise-dev/kaakati-rails-enterprise-dev-turbo-hotwire-patterns

Turbo & Hotwire Patterns Skill

This skill provides comprehensive guidance for implementing Hotwire (Turbo + Stimulus) in Ruby on Rails applications.

When to Use This Skill

  • Implementing partial page updates

  • Adding real-time features

  • Creating Turbo Frames and Streams

  • Writing Stimulus controllers

  • Debugging Turbo-related issues

External References

Hotwire Stack Overview

Hotwire ├── Turbo │ ├── Turbo Drive — Full page navigation without reload │ ├── Turbo Frames — Partial page updates │ └── Turbo Streams — Real-time updates over WebSocket/HTTP │ └── Stimulus — Lightweight JavaScript controllers

Turbo Drive

Automatically converts all link clicks and form submissions into AJAX requests.

Disabling for Specific Links

<%# Skip Turbo Drive for this link %> <%= link_to "External", "https://example.com", data: { turbo: false } %>

<%# Skip for form %> <%= form_with model: @user, data: { turbo: false } do |f| %>

Progress Bar

/* Customize Turbo progress bar */ .turbo-progress-bar { background-color: #4f46e5; height: 3px; }

Turbo Frames

Partial page updates within a frame boundary.

Basic Frame

<%# app/views/tasks/index.html.erb %> <%= turbo_frame_tag "tasks_list" do %> <% @tasks.each do |task| %> <%= render task %> <% end %>

<%= link_to "Load more", tasks_path(page: @next_page) %> <% end %>

Frame Navigation

<%# Links within frame navigate inside frame %> <%= turbo_frame_tag dom_id(@task) do %> <h3><%= @task.title %></h3> <%= link_to "Edit", edit_task_path(@task) %> <% end %>

<%# Edit form replaces frame content %> <%# app/views/tasks/edit.html.erb %> <%= turbo_frame_tag dom_id(@task) do %> <%= render "form", task: @task %> <% end %>

Breaking Out of Frame

<%# Target another frame %> <%= link_to "Details", task_path(@task), data: { turbo_frame: "task_detail" } %>

<%# Target the whole page %> <%= link_to "Full Page", task_path(@task), data: { turbo_frame: "_top" } %>

Lazy Loading Frames

<%# Load content when frame becomes visible %> <%= turbo_frame_tag "comments", src: task_comments_path(@task), loading: :lazy do %> <p>Loading comments...</p> <% end %>

Frame with Different Source

<%# Frame that loads from different URL %> <%= turbo_frame_tag "sidebar", src: sidebar_path, target: "_top" do %> <p>Loading sidebar...</p> <% end %>

Turbo Streams

Real-time DOM updates via WebSocket or HTTP responses.

Stream Actions

<%# Append to container %> <%= turbo_stream.append "tasks" do %> <%= render @task %> <% end %>

<%# Prepend to container %> <%= turbo_stream.prepend "tasks" do %> <%= render @task %> <% end %>

<%# Replace specific element %> <%= turbo_stream.replace dom_id(@task) do %> <%= render @task %> <% end %>

<%# Update contents (not replace element) %> <%= turbo_stream.update "task_count" do %> <%= @tasks.count %> <% end %>

<%# Remove element %> <%= turbo_stream.remove dom_id(@task) %>

<%# Before/After %> <%= turbo_stream.before dom_id(@task) do %> <div class="alert">Task updated!</div> <% end %>

<%= turbo_stream.after dom_id(@task) do %> <div class="related">Related tasks...</div> <% end %>

Stream Response from Controller

app/controllers/tasks_controller.rb

class TasksController < ApplicationController def create @task = current_account.tasks.build(task_params)

respond_to do |format|
  if @task.save
    format.turbo_stream  # Renders create.turbo_stream.erb
    format.html { redirect_to @task }
  else
    format.turbo_stream do
      render turbo_stream: turbo_stream.replace(
        "task_form",
        partial: "form",
        locals: { task: @task }
      )
    end
    format.html { render :new }
  end
end

end

def destroy @task = current_account.tasks.find(params[:id]) @task.destroy

respond_to do |format|
  format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@task)) }
  format.html { redirect_to tasks_path }
end

end end

<%# app/views/tasks/create.turbo_stream.erb %> <%= turbo_stream.prepend "tasks" do %> <%= render @task %> <% end %>

<%= turbo_stream.replace "task_form" do %> <%= render "form", task: Task.new %> <% end %>

<%= turbo_stream.update "tasks_count" do %> <%= current_account.tasks.count %> <% end %>

Broadcast Streams (Real-time)

app/models/task.rb

class Task < ApplicationRecord after_create_commit -> { broadcast_prepend_to "tasks" } after_update_commit -> { broadcast_replace_to "tasks" } after_destroy_commit -> { broadcast_remove_to "tasks" }

Or with custom stream name

after_create_commit -> { broadcast_prepend_to [account, "tasks"], target: "tasks_list", partial: "tasks/task" } end

<%# Subscribe to stream in view %> <%= turbo_stream_from @account, "tasks" %>

<div id="tasks_list"> <%= render @tasks %> </div>

Stimulus Controllers

Lightweight JavaScript behaviors.

Basic Controller

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

export default class extends Controller { connect() { console.log("Hello controller connected!") }

greet() { alert("Hello, Stimulus!") } }

<div data-controller="hello"> <button data-action="click->hello#greet">Greet</button> </div>

Targets

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

export default class extends Controller { static targets = ["input", "results", "count"]

search() { const query = this.inputTarget.value

fetch(`/search?q=${query}`)
  .then(response => response.text())
  .then(html => {
    this.resultsTarget.innerHTML = html
  })

}

clear() { this.inputTarget.value = "" this.resultsTarget.innerHTML = "" }

// Check if target exists updateCount() { if (this.hasCountTarget) { this.countTarget.textContent = this.resultsTarget.children.length } } }

<div data-controller="search"> <input data-search-target="input" data-action="input->search#search">

<button data-action="click->search#clear">Clear</button>

<span data-search-target="count"></span>

<div data-search-target="results"></div> </div>

Values

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

export default class extends Controller { static values = { seconds: { type: Number, default: 60 }, url: String, autoStart: { type: Boolean, default: false } }

connect() { if (this.autoStartValue) { this.start() } }

start() { this.remaining = this.secondsValue this.timer = setInterval(() => this.tick(), 1000) }

tick() { if (this.remaining > 0) { this.remaining-- this.element.textContent = this.remaining } else { this.finish() } }

finish() { clearInterval(this.timer) if (this.hasUrlValue) { window.location.href = this.urlValue } }

// Called when value changes secondsValueChanged() { this.remaining = this.secondsValue }

disconnect() { clearInterval(this.timer) } }

<div data-controller="countdown" data-countdown-seconds-value="30" data-countdown-url-value="/timeout" data-countdown-auto-start-value="true"> 30 </div>

Actions

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

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

// Default action (no method specified) submit(event) { event.preventDefault() this.submitTarget.disabled = true // ... form submission logic }

// With event options // data-action="keydown.enter->form#submit" // data-action="click->form#submit:prevent" }

<form data-controller="form" data-action="submit->form#submit">

<input data-action="keydown.enter->form#submit:prevent">

<button data-form-target="submit" data-action="click->form#validate"> Submit </button> </form>

Classes

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

export default class extends Controller { static classes = ["open", "closed"] static targets = ["menu"]

toggle() { if (this.menuTarget.classList.contains(this.openClass)) { this.close() } else { this.open() } }

open() { this.menuTarget.classList.remove(this.closedClass) this.menuTarget.classList.add(this.openClass) }

close() { this.menuTarget.classList.remove(this.openClass) this.menuTarget.classList.add(this.closedClass) } }

<div data-controller="dropdown" data-dropdown-open-class="block" data-dropdown-closed-class="hidden">

<button data-action="click->dropdown#toggle">Menu</button>

<div data-dropdown-target="menu" class="hidden"> Menu content </div> </div>

Outlets (Controller Communication)

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

export default class extends Controller { static outlets = ["form"]

open() { this.element.classList.add("open")

// Call method on connected form controller
if (this.hasFormOutlet) {
  this.formOutlet.reset()
}

}

close() { this.element.classList.remove("open") } }

<div data-controller="modal" data-modal-form-outlet="#task-form">

<div id="task-form" data-controller="form"> <!-- form content --> </div> </div>

Common Patterns

Infinite Scroll

<%# View %> <div data-controller="infinite-scroll" data-infinite-scroll-url-value="<%= tasks_path %>" data-infinite-scroll-page-value="1">

<div id="tasks" data-infinite-scroll-target="container"> <%= render @tasks %> </div>

<div data-infinite-scroll-target="loading" class="hidden"> Loading... </div> </div>

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

export default class extends Controller { static targets = ["container", "loading"] static values = { url: String, page: Number }

connect() { this.observer = new IntersectionObserver( entries => this.handleIntersect(entries), { threshold: 0.1 } ) this.observer.observe(this.loadingTarget) }

handleIntersect(entries) { entries.forEach(entry => { if (entry.isIntersecting) { this.loadMore() } }) }

async loadMore() { this.loadingTarget.classList.remove("hidden")

const response = await fetch(
  `${this.urlValue}?page=${this.pageValue + 1}`,
  { headers: { "Accept": "text/vnd.turbo-stream.html" } }
)

if (response.ok) {
  this.pageValue++
  const html = await response.text()
  Turbo.renderStreamMessage(html)
}

this.loadingTarget.classList.add("hidden")

}

disconnect() { this.observer.disconnect() } }

Auto-Submit Form

<%= form_with url: search_path, method: :get, data: { controller: "auto-submit", turbo_frame: "results" } do |f| %>

<%= f.text_field :q, data: { action: "input->auto-submit#submit", auto_submit_target: "input" } %> <% end %>

<%= turbo_frame_tag "results" do %> <%= render @results %> <% end %>

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

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

submit() { clearTimeout(this.timeout) this.timeout = setTimeout(() => { this.element.requestSubmit() }, 300) } }

Flash Messages with Turbo

<%# app/views/layouts/_flash.html.erb %> <div id="flash"> <% flash.each do |type, message| %> <div class="flash flash-<%= type %>" data-controller="flash" data-flash-timeout-value="5000"> <%= message %> <button data-action="click->flash#dismiss">×</button> </div> <% end %> </div>

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

export default class extends Controller { static values = { timeout: { type: Number, default: 5000 } }

connect() { this.timer = setTimeout(() => this.dismiss(), this.timeoutValue) }

dismiss() { this.element.remove() }

disconnect() { clearTimeout(this.timer) } }

Turbo 8 Modern Features

Page Refresh (Turbo 8+)

Morphing - Update page without full reload, preserving scroll and focus:

<!-- Enable morphing globally --> <meta name="turbo-refresh-method" content="morph">

<!-- Or per-page --> <meta name="turbo-refresh-method" content="replace">

Controller - trigger page refresh

class TasksController < ApplicationController def update @task.update(task_params)

# Send refresh signal to clients
respond_to do |format|
  format.html { redirect_to tasks_path }
  format.turbo_stream {
    render turbo_stream: turbo_stream.action(:refresh)
  }
end

end end

Morph Refresh

Preserve elements during morph:

<!-- Element persists across morphs --> <div id="video-player" data-turbo-permanent> <video src="movie.mp4" controls></video> </div>

<!-- Input state persists --> <input type="text" data-turbo-permanent>

View Transitions API Integration

/* Smooth transitions during Turbo navigation */ @view-transition { navigation: auto; }

::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; }

/* Custom transition for specific elements */ .task-card { view-transition-name: task-card; }

Turbo Native (Mobile Apps)

Basic Setup

// iOS - SceneDelegate.swift import Turbo

class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var navigationController = UINavigationController()

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }

    window = UIWindow(windowScene: windowScene)
    window?.rootViewController = navigationController
    window?.makeKeyAndVisible()

    visit(url: URL(string: "https://example.com")!)
}

func visit(url: URL) {
    let viewController = VisitableViewController(url: url)
    navigationController.pushViewController(viewController, animated: true)
}

}

// Android - MainActivity.kt import dev.hotwire.turbo.session.Session import dev.hotwire.turbo.visit.TurboVisitOptions

class MainActivity : AppCompatActivity(), TurboActivity { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)

    TurboSessionNavHostFragment.visit(
        url = "https://example.com",
        options = TurboVisitOptions(action = TurboVisitAction.ADVANCE)
    )
}

}

Native Bridge Patterns

<!-- app/views/tasks/show.html.erb --> <% if turbo_native_app? %> <%= link_to "Share", "#", data: { turbo_frame: "_top", controller: "bridge", action: "click->bridge#share" } %> <% end %>

// app/javascript/controllers/bridge_controller.js import { BridgeComponent } from "@hotwired/turbo-ios"

export default class extends BridgeComponent { share() { this.send("share", { title: "Task Title", url: window.location.href }) } }

Form Validation with Turbo

Client-Side Validation

<%= form_with model: @task, data: { controller: "form-validation", action: "turbo:submit-end->form-validation#handleResponse" } do |f| %>

<%= f.text_field :title, required: true, minlength: 5, data: { form_validation_target: "field", action: "blur->form-validation#validateField" } %> <span data-form-validation-target="error" class="hidden text-red-500"></span>

<%= f.submit "Save", data: { form_validation_target: "submit" } %> <% end %>

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

export default class extends Controller { static targets = ["field", "error", "submit"]

validateField(event) { const field = event.target const error = field.parentElement.querySelector('[data-form-validation-target="error"]')

if (!field.validity.valid) {
  error.textContent = field.validationMessage
  error.classList.remove("hidden")
  field.classList.add("border-red-500")
} else {
  error.classList.add("hidden")
  field.classList.remove("border-red-500")
}

}

handleResponse(event) { const { success, fetchResponse } = event.detail

if (!success &#x26;&#x26; fetchResponse.response.status === 422) {
  // Server returned validation errors
  this.disableSubmit(false)
}

}

disableSubmit(disabled) { this.submitTarget.disabled = disabled } }

Server-Side Validation with Turbo

app/controllers/tasks_controller.rb

def create @task = Task.new(task_params)

respond_to do |format| if @task.save format.turbo_stream { render turbo_stream: [ turbo_stream.prepend("tasks", partial: "tasks/task", locals: { task: @task }), turbo_stream.replace("task_form", partial: "tasks/form", locals: { task: Task.new }) ] } else format.turbo_stream { render turbo_stream: turbo_stream.replace( "task_form", partial: "tasks/form", locals: { task: @task } ), status: :unprocessable_entity } end end end

<!-- app/views/tasks/_form.html.erb --> <%= turbo_frame_tag "task_form" do %> <%= form_with model: task do |f| %> <div class="field"> <%= f.label :title %> <%= f.text_field :title, class: task.errors[:title].any? ? 'error' : '' %> <% if task.errors[:title].any? %> <span class="error-message"><%= task.errors[:title].first %></span> <% end %> </div>

&#x3C;%= f.submit %>

<% end %> <% end %>

Error Handling Patterns

Turbo Stream Error Responses

app/controllers/concerns/turbo_streamable_errors.rb

module TurboStreamableErrors extend ActiveSupport::Concern

included do rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found rescue_from StandardError, with: :handle_error end

private

def handle_not_found(exception) respond_to do |format| format.turbo_stream { render turbo_stream: turbo_stream.replace( "flash", partial: "shared/flash", locals: { message: "Record not found", type: "error" } ), status: :not_found } format.html { redirect_to root_path, alert: "Record not found" } end end

def handle_error(exception) Rails.logger.error(exception.message)

respond_to do |format|
  format.turbo_stream {
    render turbo_stream: turbo_stream.replace(
      "flash",
      partial: "shared/flash",
      locals: { message: "An error occurred", type: "error" }
    ), status: :internal_server_error
  }
  format.html { redirect_to root_path, alert: "An error occurred" }
end

end end

Handling Network Errors

// app/javascript/application.js document.addEventListener("turbo:fetch-request-error", (event) => { const { detail: { fetchResponse } } = event

if (!fetchResponse || fetchResponse.response.status >= 500) { // Show offline/error UI document.getElementById("error-banner").classList.remove("hidden") } })

document.addEventListener("turbo:frame-missing", (event) => { // Handle missing frame gracefully const frame = event.target frame.innerHTML = &#x3C;div class="alert alert-warning"> Content could not be loaded. &#x3C;a href="${frame.src}">Try again&#x3C;/a> &#x3C;/div> event.preventDefault() })

Progressive Enhancement

No-JS Fallbacks

<!-- Works without JavaScript --> <%= form_with model: @task do |f| %> <!-- Form works with or without Turbo --> <%= f.text_field :title %> <%= f.submit %> <% end %>

<!-- Link works without Turbo --> <%= link_to "View", task_path(@task) %>

<!-- Progressive Turbo Frame --> <turbo-frame id="comments" src="<%= task_comments_path(@task) %>"> <!-- Fallback content shown during load and without JS --> <a href="<%= task_comments_path(@task) %>">View comments</a> </turbo-frame>

Feature Detection

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

export default class extends Controller { connect() { // Check for required features if ('IntersectionObserver' in window) { this.enableLazyLoading() }

if ('fetch' in window) {
  this.enableAjaxFeatures()
}

}

enableLazyLoading() { // Use IntersectionObserver for lazy loading }

enableAjaxFeatures() { // Enable AJAX-dependent features } }

Accessibility with Turbo/Stimulus

ARIA Live Regions

<!-- Announce dynamic updates to screen readers --> <div id="tasks" aria-live="polite" aria-atomic="false"> <%= render @tasks %> </div>

<div id="flash" role="status" aria-live="assertive" aria-atomic="true"> <!-- Flash messages announced immediately --> </div>

Keyboard Navigation

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

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

connect() { this.currentIndex = 0 this.itemTargets[this.currentIndex]?.focus() }

next(event) { if (event.key === "ArrowDown") { event.preventDefault() this.currentIndex = Math.min(this.currentIndex + 1, this.itemTargets.length - 1) this.itemTargets[this.currentIndex].focus() } }

previous(event) { if (event.key === "ArrowUp") { event.preventDefault() this.currentIndex = Math.max(this.currentIndex - 1, 0) this.itemTargets[this.currentIndex].focus() } }

select(event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault() event.target.click() } } }

<div data-controller="keyboard-nav" tabindex="0" data-action="keydown->keyboard-nav#next keydown->keyboard-nav#previous">

<% @items.each do |item| %> <div data-keyboard-nav-target="item" tabindex="0" role="button" aria-label="<%= item.title %>" data-action="keydown->keyboard-nav#select"> <%= item.title %> </div> <% end %> </div>

Focus Management

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

export default class extends Controller { static targets = ["dialog", "closeButton"]

open() { this.previousFocus = document.activeElement this.dialogTarget.showModal() this.closeButtonTarget.focus()

// Trap focus within modal
this.dialogTarget.addEventListener("keydown", this.trapFocus.bind(this))

}

close() { this.dialogTarget.close() this.previousFocus?.focus() }

trapFocus(event) { if (event.key === "Tab") { const focusableElements = this.dialogTarget.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) const firstElement = focusableElements[0] const lastElement = focusableElements[focusableElements.length - 1]

  if (event.shiftKey &#x26;&#x26; document.activeElement === firstElement) {
    lastElement.focus()
    event.preventDefault()
  } else if (!event.shiftKey &#x26;&#x26; document.activeElement === lastElement) {
    firstElement.focus()
    event.preventDefault()
  }
}

} }

Testing Turbo and Stimulus

System Tests for Turbo

spec/system/tasks_spec.rb

require 'rails_helper'

RSpec.describe "Tasks", type: :system do before do driven_by(:selenium_chrome_headless) end

it "creates a task with Turbo" do visit tasks_path

within "#task_form" do
  fill_in "Title", with: "New Task"
  click_button "Create"
end

# Verify Turbo update without page reload
expect(page).to have_content("New Task")
expect(page).to have_current_path(tasks_path) # No redirect
expect(page).to have_selector("#task_form input[value='']") # Form reset

end

it "updates task via Turbo Stream" do task = create(:task, title: "Old Title") visit tasks_path

within "##{dom_id(task)}" do
  click_link "Edit"
  fill_in "Title", with: "New Title"
  click_button "Update"
end

# Frame updated in place
within "##{dom_id(task)}" do
  expect(page).to have_content("New Title")
  expect(page).not_to have_field("Title")
end

end

it "handles validation errors with Turbo" do visit tasks_path

within "#task_form" do
  fill_in "Title", with: "" # Invalid
  click_button "Create"
end

expect(page).to have_content("can't be blank")
expect(page).to have_selector("#task_form") # Form still visible

end end

Testing Stimulus Controllers

// spec/javascript/controllers/search_controller.test.js import { Application } from "@hotwired/stimulus" import SearchController from "controllers/search_controller"

describe("SearchController", () => { let application let controller

beforeEach(() => { document.body.innerHTML = &#x3C;div data-controller="search"> &#x3C;input data-search-target="input" type="text"> &#x3C;div data-search-target="results">&#x3C;/div> &#x3C;/div>

application = Application.start()
application.register("search", SearchController)
controller = application.getControllerForElementAndIdentifier(
  document.querySelector('[data-controller="search"]'),
  "search"
)

})

afterEach(() => { application.stop() })

it("clears input and results", () => { controller.inputTarget.value = "test query" controller.resultsTarget.innerHTML = "<div>Results</div>"

controller.clear()

expect(controller.inputTarget.value).toBe("")
expect(controller.resultsTarget.innerHTML).toBe("")

})

it("searches when input changes", async () => { global.fetch = jest.fn(() => Promise.resolve({ text: () => Promise.resolve("<div>Search results</div>") }) )

controller.inputTarget.value = "rails"
await controller.search()

expect(global.fetch).toHaveBeenCalledWith("/search?q=rails")
expect(controller.resultsTarget.innerHTML).toContain("Search results")

}) })

Debouncing and Throttling

Debounce Pattern

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

export default class extends Controller { static values = { delay: { type: Number, default: 300 } } static targets = ["input", "results"]

search() { clearTimeout(this.timeout)

this.timeout = setTimeout(() => {
  this.performSearch()
}, this.delayValue)

}

async performSearch() { const query = this.inputTarget.value

if (query.length &#x3C; 2) return

const response = await fetch(`/search?q=${encodeURIComponent(query)}`)
const html = await response.text()
this.resultsTarget.innerHTML = html

}

disconnect() { clearTimeout(this.timeout) } }

Throttle Pattern

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

export default class extends Controller { static values = { interval: { type: Number, default: 200 } }

connect() { this.lastRun = 0 this.element.addEventListener("scroll", this.handleScroll.bind(this)) }

handleScroll() { const now = Date.now()

if (now - this.lastRun >= this.intervalValue) {
  this.track()
  this.lastRun = now
}

}

track() { const scrollPercentage = (this.element.scrollTop / this.element.scrollHeight) * 100 console.log(Scrolled ${scrollPercentage}%)

// Send analytics, etc.

} }

Stimulus Components Integration

// Using stimulus-components library import { Application } from "@hotwired/stimulus" import Dropdown from "@stimulus-components/dropdown" import Notification from "@stimulus-components/notification" import Popover from "@stimulus-components/popover"

const application = Application.start() application.register("dropdown", Dropdown) application.register("notification", Notification) application.register("popover", Popover)

<!-- Dropdown component --> <div data-controller="dropdown"> <button data-action="dropdown#toggle">Menu</button> <div data-dropdown-target="menu"> <a href="/profile">Profile</a> <a href="/settings">Settings</a> </div> </div>

<!-- Notification component --> <div data-controller="notification" data-notification-delay-value="5000" data-notification-remove-after-value="true"> <p>Your task was created successfully!</p> <button data-action="notification#hide">×</button> </div>

<!-- Popover component --> <div data-controller="popover" data-popover-translate-x="-50%" data-popover-translate-y="8"> <button data-action="popover#toggle">Show Info</button> <div data-popover-target="card" class="hidden"> Popover content </div> </div>

Debugging

Turbo Events

// Listen to Turbo events for debugging document.addEventListener("turbo:before-fetch-request", (event) => { console.log("Turbo request:", event.detail.url) })

document.addEventListener("turbo:frame-missing", (event) => { console.log("Frame missing:", event.target.id) })

// Log all Turbo events [ "turbo:click", "turbo:before-visit", "turbo:visit", "turbo:before-fetch-request", "turbo:before-fetch-response", "turbo:submit-start", "turbo:submit-end", "turbo:before-stream-render", "turbo:before-frame-render", "turbo:frame-render", "turbo:frame-load", "turbo:load" ].forEach(event => { document.addEventListener(event, (e) => console.log(event, e.detail)) })

Common Issues

  • Frame not updating: Check frame IDs match between source and target

  • Streams not working: Verify turbo_stream_from subscription

  • Actions not firing: Check data-action syntax and controller registration

  • Morphing issues: Use data-turbo-permanent for persistent elements

  • Focus loss: Implement focus management in Stimulus controllers

  • Screen reader issues: Add proper ARIA attributes and live regions

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

flutter conventions & best practices

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

getx state management patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

ruby oop patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

rails localization (i18n) - english & arabic

No summary provided by upstream source.

Repository SourceNeeds Review