Effects
Make the reactivity visible: rows fade in and out of existence and updates flash — with zero app JS.
Try it#
Pick a style, then Flash the card (an update effect on a reply.replace), Add row (an enter effect riding the append), and dismiss a row (its exit runs before the element leaves the DOM). Every trigger here uses the per-call effect: form with the picked style held in signed state.
# frozen_string_literal: true
# The live effects gallery (issue #215). Pick a style, then trigger each hook:
# "Flash" re-renders the gallery with a per-call update effect, "Add row"
# appends a row whose arrival animates (per-call enter), and each row's ×
# animates its exit. Everything here is the PER-CALL form — reply.replace(
# effect:), Row.append(..., effect:), reply.remove(effect:) — with the picked
# style held in signed state; the Effects guide shows the global + per-class
# declarations the rest of the site's demos use.
class EffectsGalleryComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
STYLES = EffectsGalleryRowComponent::STYLES
reactive_state :style, :next_n
action :set_style, params: { style: :string }
action :flash_card
action :add_row
def initialize(style: 'fade', next_n: 1)
@style = STYLES.include?(style) ? style : 'fade'
@next_n = next_n
end
def id = 'effects-gallery'
# The style param is CLIENT input — whitelist it before it ever reaches an
# effect: kwarg (an unknown name would raise server-side by design).
def set_style(style:)
@style = style if STYLES.include?(style)
reply.replace
end
def flash_card = reply.replace(effect: @style.to_sym)
def add_row
stream = EffectsGalleryRowComponent.append(
target: 'effects-gallery-rows', n: @next_n, style: @style, effect: @style.to_sym
)
@next_n += 1
# No self re-render (a replace would clobber the rows already added) — the
# token-only refresh keeps repeated adds verifying.
reply.streams(stream)
end
def view_template
div(**reactive_root(class: 'flex flex-col gap-4', data: { testid: 'effects-gallery' })) do
style_picker
div(class: 'flex gap-2') do
button(**mix(on(:flash_card), class: 'btn btn-sm btn-primary', data: { testid: 'fx-flash' })) do
"Flash (update: #{@style})"
end
button(**mix(on(:add_row), class: 'btn btn-sm', data: { testid: 'fx-add' })) do
"Add row (enter: #{@style})"
end
end
ul(id: 'effects-gallery-rows', class: 'flex flex-col gap-2')
end
end
private
def style_picker
div(class: 'join') do
STYLES.each do |name|
button(**mix(on(:set_style, style: name),
class: "join-item btn btn-xs #{'btn-active' if name == @style}",
data: { testid: "fx-style-#{name}" })) { name }
end
end
end
endThree opt-in levels#
Most specific wins; off by default.
Effects are off unless you opt in — with them off the rendered wire is byte-identical to previous releases.
# 1. GLOBAL — setting it is the opt-in AND the app-wide default set:
Phlex::Reactive.effects = true # { enter: :fade, exit: :fade, update: :highlight }
Phlex::Reactive.effects = { enter: :slide, exit: :fade, update: :highlight }
# 2. PER COMPONENT — refine the global set, or opt in standalone:
class Notifications::Row < ApplicationComponent
reactive_effects enter: :slide, exit: :fade
# reactive_effects update: false # disable one hook
# reactive_effects false # opt this component out entirely
# reactive_effects enter: :random # a random built-in per application
end
# 3. PER CALL — override one stream:
reply.remove(effect: :shake)
reply.append(item, to: :items, effect: :scale)
Item.replace(@todo, effect: false) # suppress a declared effect once
Row.broadcast_to(@list, :todos, append: todo, target: 'rows', effect: :slide)A component-level declaration works without the global switch — declaring reactive_effects on a component is that component's opt-in. Unknown effect names raise at class load (never at click time), and the client warns-and-skips anything it doesn't recognize — the same two-sided default-deny as every other wire surface.
The three hooks#
Each hook maps to the Turbo Stream actions that mean "something entered / left / changed":
| Hook | Stream actions | When it runs |
|---|---|---|
enter | append, prepend | after render, on the arriving element |
exit | remove | before the element leaves — the removal waits for the animation |
update | replace, update (plain or morph) | after render, on the fresh root |
Effects fire for the actor's own reply and for broadcasts alike — one interceptor covers both delivery paths, identically on Action Cable and pgbus. If a debounced-save grid flashes too much, turn that one hook off where it's noisy: reactive_effects update: false.
Built-ins (shipped CSS)#
Link the engine stylesheet once:
<%= stylesheet_link_tag "phlex/reactive/effects" %>Five named effects work on every hook: :fade, :slide, :scale, :highlight (the classic background flash), and :shake. :random picks one per application — every update animates differently (great for demos). Tune them with CSS custom properties:
:root {
--reactive-fx-duration: 200ms;
--reactive-fx-highlight-color: rgb(186 230 253 / 0.5);
}Keep durations comfortably under one second — the client hard-caps any effect wait at 1000 ms, so an exit longer than that is removed mid-animation.
Custom effects (class legs)#
Skip the stylesheet entirely with named class legs — the same { during:, from:, to: } vocabulary js.toggle(transition:) uses, perfect for Tailwind utilities. during classes apply for the whole animation; from swaps to to on the next frame:
reactive_effects enter: { during: %w[transition-all duration-300],
from: %w[opacity-0 translate-y-2],
to: %w[opacity-100 translate-y-0] }Exit-before-removal semantics#
A remove with an exit effect keeps the element in the DOM while the animation runs and only then lets Turbo remove it. Three guarantees make that safe:
- the wait is settled by
animationend/transitionendor a timeout just past the computed duration — an interrupted animation can't hang the removal; - a zero computed duration (the effects stylesheet isn't loaded, or reduced motion zeroed it) skips the wait entirely — a missing
stylesheet_link_tagnever freezes deletes; - everything is hard-capped at 1000 ms.
Rapid successive updates restart the flash (the class is removed, a reflow forced, and re-added), so a busy row keeps signalling.
Reduced motion & the wire#
prefers-reduced-motion: reduce disables effects twice over: the client interceptor skips them, and the shipped CSS lives inside a no-preference media query — so even a hand-built class can't animate against the user's setting.
On the wire, the resolved hooks ride the component root as data-reactive-effect-enter/exit/update attributes (omitted entirely when off), and a per-call override rides the <turbo-stream> element itself as data-reactive-effect ("off" suppresses). Values are effect names or class lists — identity and presentation, never state, and the client treats them as classList input only.