javascript erb 76 lines · 3 tabs

Autofocus first input when a Turbo modal opens (Stimulus)

Shared by codesnips Jan 2026
3 tabs
import { Controller } from "@hotwired/stimulus";

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

  connect() {
    this.previouslyFocused = document.activeElement;
    // Defer until layout settles so the element is actually focusable.
    requestAnimationFrame(() => this.focusFirstField());
  }

  focusFirstField() {
    const field = this.hasFieldTarget ? this.fieldTarget : this.firstVisibleInput();
    if (!field) return;

    field.focus();
    if (typeof field.select === "function" && field.value) {
      field.select();
    }
  }

  firstVisibleInput() {
    const candidates = this.element.querySelectorAll(
      "input:not([type=hidden]):not([disabled]), textarea:not([disabled]), select:not([disabled])"
    );
    return Array.from(candidates).find((el) => el.offsetParent !== null);
  }

  close(event) {
    if (event) event.preventDefault();
    this.element.innerHTML = "";
    if (this.previouslyFocused && this.previouslyFocused.focus) {
      this.previouslyFocused.focus();
    }
  }
}
3 files · javascript, erb Explain with highlit

This snippet shows how a Turbo-driven modal reliably focuses its first field the moment it appears, a detail that matters for both usability and accessibility. Modals loaded through a Turbo Frame arrive as fresh DOM after an async fetch, so the browser's native autofocus attribute is unreliable — the element is inserted after the initial parse, and autofocus only fires during document load. A Stimulus controller closes that gap by running focus logic in its connect lifecycle callback.

In modal_controller.js, the controller wires up on connect(), which Stimulus invokes as soon as the element enters the DOM — exactly when the Turbo Frame swaps in the modal partial. It defers the actual focus with requestAnimationFrame so the browser has finished layout before the field receives focus, avoiding a race where the element isn't yet focusable. The focusFirstField() method prefers an explicitly marked dialogTarget input via a Stimulus target, then falls back to a query for the first enabled, visible field, skipping hidden inputs and buttons. It also calls select() when the field is a text input so an existing value is highlighted for quick replacement.

The close() action demonstrates the return trip: it restores focus to previouslyFocused, the element that triggered the modal, which is captured in connect() via document.activeElement. This keeps keyboard and screen-reader users oriented after the modal dismisses.

In modal.html.erb, the frame partial declares data-controller="modal" and marks the intended input with data-modal-target="field". The turbo_frame_tag gives the modal a stable frame id so links elsewhere can target it. Note the autofocus attribute is intentionally omitted, since the controller owns focus behavior.

In new.html.erb, a link_to with data: { turbo_frame: "modal" } triggers the lazy load into the shared frame, and the empty turbo_frame_tag "modal" acts as the mount point. This division of labor — server renders the form, Stimulus manages focus — keeps the pattern reusable across any modal without duplicating JavaScript. A subtle pitfall it handles is Turbo cache restoration: because focus runs on every connect, a cached-then-restored frame still lands focus correctly.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Autofocus first input when a Turbo modal opens (Stimulus) — share card
Link copied