import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["field"]
connect() {
this.focusFirstInvalid = this.focusFirstInvalid.bind(this)
document.addEventListener("turbo:frame-render", this.focusFirstInvalid)
// Handle the initial render too (server may return errors on first paint).
this.focusFirstInvalid()
}
disconnect() {
document.removeEventListener("turbo:frame-render", this.focusFirstInvalid)
}
focusFirstInvalid() {
const invalid = this.element.querySelector(
'[aria-invalid="true"], .field-error input, .field-error textarea, .field-error select'
)
if (!invalid) return
requestAnimationFrame(() => {
invalid.focus({ preventScroll: true })
invalid.scrollIntoView({ behavior: "smooth", block: "center" })
})
}
}
<%= turbo_frame_tag dom_id(subscription, :form) do %>
<%= form_with model: subscription,
data: { controller: "focus-invalid" },
builder: AriaFormBuilder do |f| %>
<% if subscription.errors.any? %>
<div class="error-banner" role="alert">
<%= pluralize(subscription.errors.count, "error") %> prevented saving.
</div>
<% end %>
<div class="field <%= "field-error" if subscription.errors[:email].any? %>">
<%= f.label :email %>
<%= f.email_field :email, data: { "focus-invalid-target": "field" } %>
</div>
<div class="field <%= "field-error" if subscription.errors[:plan].any? %>">
<%= f.label :plan %>
<%= f.select :plan, Subscription::PLANS,
{}, data: { "focus-invalid-target": "field" } %>
</div>
<%= f.submit "Save" %>
<% end %>
<% end %>
class AriaFormBuilder < ActionView::Helpers::FormBuilder
%i[text_field email_field password_field select].each do |helper|
define_method(helper) do |method, *args, &block|
options = args.extract_options!
options = mark_invalid(method, options)
super(method, *args, options, &block)
end
end
private
def mark_invalid(method, options)
return options unless object.respond_to?(:errors)
if object.errors[method].any?
options = options.merge("aria-invalid" => "true")
described_by = object.errors[method].present? ? "#{method}_error" : nil
options["aria-describedby"] = described_by if described_by
end
options
end
end
This snippet shows how a small Stimulus controller improves form-validation feedback in a Hotwire/Turbo application. When a server-rendered form comes back with validation errors, Turbo replaces the form's frame in place; there is no full page load, so the browser never re-applies the usual autofocus behavior and the user is left staring at an error banner with no obvious place to fix things. The controller in focus_invalid_controller.js closes that gap by scanning the freshly rendered markup for the first field marked invalid and moving focus (and the scroll viewport) to it.
The controller registers a static targets list of field elements and listens for the Turbo lifecycle rather than DOM ready. In connect, it binds focusFirstInvalid to turbo:frame-render, which fires every time a turbo-frame swaps in new content, so the logic re-runs on each failed submission without any manual wiring. disconnect removes the listener to avoid leaks when the controller element is torn down.
The core focusFirstInvalid method prefers ARIA state over CSS classes: it queries for [aria-invalid="true"], falling back to .field-error input, so the behavior is driven by the same attributes that assistive technology reads. Once found, it calls focus() with preventScroll and then scrollIntoView with smooth centering, which gives a controlled, non-jarring movement instead of the browser's abrupt default jump. A requestAnimationFrame wrapper ensures the element is actually laid out before focus is attempted, avoiding a race where the frame content is present in the DOM but not yet focusable.
The accompanying _form.html.erb shows the Rails side: data-controller, the turbo_frame_tag, and how each field stamps aria-invalid from object.errors. The field-error wrapper class keeps the CSS fallback selector meaningful. Because the attribute is rendered by the server, the client stays a pure presentation concern and never needs to know the validation rules. The trade-off is a dependence on consistent ARIA markup; if a template forgets aria-invalid, the field simply will not be focused, which fails safe rather than throwing. This pattern is worth reaching for whenever inline validation lives inside a Turbo Frame and keyboard and screen-reader users need to land on the problem immediately.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
<!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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.