erb javascript ruby 66 lines · 4 tabs

Scroll into view after append using a custom Turbo Stream action

Shared by codesnips Jan 2026
4 tabs
<%# 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>
4 files · erb, javascript, ruby Explain with highlit

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

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
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
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Scroll into view after append using a custom Turbo Stream action — share card
Link copied