<%# Append is also broadcast from the model; this response scrolls the author's view %>
<%= turbo_stream.append "messages" do %>
<%= render partial: "messages/message", locals: { message: @message } %>
<% end %>
<turbo-stream action="scroll_to" target="<%= dom_id(@message) %>" behavior="smooth" block="end">
</turbo-stream>
import { Turbo } from "@hotwired/turbo-rails"
Turbo.StreamActions.scroll_to = function () {
const id = this.getAttribute("target")
const element = document.getElementById(id)
if (!element) return
const behavior = this.getAttribute("behavior") || "smooth"
const block = this.getAttribute("block") || "end"
element.scrollIntoView({ behavior, block })
}
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :user
validates :body, presence: true
after_create_commit :broadcast_to_conversation
private
def broadcast_to_conversation
broadcast_append_to(
conversation,
target: "messages",
partial: "messages/message",
locals: { message: self }
)
end
end
class MessagesController < ApplicationController
before_action :set_conversation
def create
@message = @conversation.messages.build(message_params)
@message.user = current_user
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @conversation }
end
else
render :new, status: :unprocessable_entity
end
end
private
def set_conversation
@conversation = current_user.conversations.find(params[:conversation_id])
end
def message_params
params.require(:message).permit(:body)
end
end
This snippet shows how to define a custom Turbo Stream action in a Rails/Hotwire app so that a message appended over a websocket is scrolled into view without any per-view JavaScript. Turbo ships with built-in actions like append, prepend, and replace, but it also exposes Turbo.StreamActions, a plain object that maps action names to functions invoked with this bound to the <turbo-stream> element. Registering a custom action there is the idiomatic way to extend the vocabulary of streams.
In stream_actions.js, a scroll_to action is registered. It reads a target attribute, resolves the element by id, and calls scrollIntoView with smooth behavior. The guard against a missing element matters because streams can arrive after the DOM node has been removed (for example the user navigated away from the conversation), and calling scrollIntoView on null would throw and abort processing of later stream elements in the same frame. Because the action is a no-op when nothing matches, it degrades gracefully.
The server side lives in Message model and messages/create.turbo_stream.erb. The model uses after_create_commit with broadcast_append_to to push the new row into the messages list for every subscriber, committing only after the transaction so consumers never see a phantom row. That broadcast handles the append itself.
The interesting part is messages/create.turbo_stream.erb, the response to the creator's own form submission. It first appends the rendered message, then emits a raw <turbo-stream action="scroll_to"> whose target is the DOM id of the just-created record via dom_id. Turbo does not care that scroll_to is not a native action — it looks the name up in Turbo.StreamActions and runs the registered function. Splitting the append and the scroll into two stream elements keeps each one single-purpose and lets the append come from the model broadcast while the scroll stays request-specific.
A subtle trade-off is ordering: the scroll element must come after the append so the target exists when scroll_to runs, since Turbo processes stream children top to bottom. This pattern is worth reaching for whenever a UI needs an imperative side effect (scroll, focus, flash a toast) tied to a DOM mutation, without scattering MutationObserver logic or controllers across every template.
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.