import { StreamActions } from "@hotwired/turbo"
StreamActions.highlight = function () {
const className = this.getAttribute("data-highlight-class") || "flash-highlight"
const duration = parseInt(this.getAttribute("data-highlight-duration"), 10) || 1500
this.targetElements.forEach((element) => {
// Force a reflow so the class add always triggers the transition.
element.classList.remove(className)
void element.offsetWidth
element.classList.add(className)
window.setTimeout(() => {
element.classList.remove(className)
}, duration)
})
}
import "@hotwired/turbo-rails"
import "./controllers"
// Registers StreamActions.highlight before any stream can reference it.
import "./highlight_stream_action"
.highlightable {
transition: background-color 400ms ease-out;
background-color: transparent;
}
.highlightable.flash-highlight {
background-color: #fff3bf; // soft amber cue
}
<%= turbo_stream.replace dom_id(@item) do %>
<%= render partial: "items/item", locals: { item: @item } %>
<% end %>
<turbo-stream
action="highlight"
target="<%= dom_id(@item) %>"
data-highlight-class="flash-highlight"
data-highlight-duration="1500">
<template></template>
</turbo-stream>
Turbo Streams ship with a fixed vocabulary of actions — append, replace, update, remove, and a few more — but the underlying StreamActions object is an ordinary JavaScript map that can be extended. This snippet registers a custom highlight action so the server can tell the browser to briefly flash an element after it changes, giving users a visual cue that a value updated without any bespoke controller wiring per feature.
The first tab, highlight_stream_action.js, defines the action by assigning a function to StreamActions.highlight. Inside that function this is the <turbo-stream> element itself, so this.targetElements resolves the CSS selector in the target/targets attribute to real DOM nodes. Custom data-* attributes on the stream tag flow through via this.getAttribute, which is how data-highlight-class and data-highlight-duration are read. The action toggles a class, then removes it after a timeout so the CSS transition can animate back to the resting state. Because it is registered once at import time, every stream the app renders — over WebSocket, SSE, or a form response — can use it.
The second tab, application.js, is the entrypoint that imports the action module for its side effect. It must run before any stream referencing highlight arrives; importing it alongside the Turbo setup guarantees StreamActions.highlight exists when Turbo processes an incoming frame.
The third tab, flash.scss, backs the effect. The base class defines a transition on background-color, and the .flash-highlight modifier sets the highlighted color. Putting the animation in CSS keeps the JavaScript trivial and lets designers tune timing independently.
The fourth tab, update.turbo_stream.erb, shows the server side: a standard turbo_stream.replace swaps the row, and a second, hand-written <turbo-stream action="highlight"> targets the same DOM id to trigger the flash. Note the duration passed as data-highlight-duration must comfortably exceed the CSS transition, otherwise the class is stripped before the animation finishes. This pattern is worth reaching for when many views need the same post-update affordance; a custom action centralizes the behavior instead of scattering Stimulus controllers everywhere.
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.