Leave and Enter Transitions

A town notice board that anyone pins to and tears from. A notice you tear down slides away. Notes that another visitor tears, or a filter hides, are gone at once. The motion is plain CSS, modulized as one import.

The town notice board

  • Lost One left boot in the cabbage patch. Much missed.
  • For sale A good goose sold as seen. Gone by Friday.
  • Wanted A night watchman at the east gate. Bring your own boots.
  • Lost A spotted pig in the cabbage patch. Return to the chapel.
  • For sale A spinning wheel going cheap. Gone by Friday.
  • Wanted A steady hand at the east gate. No time wasters.
  • Lost A grey cat on the mill road. Return to the chapel.
  • For sale A bronze kettle sold as seen. Ask for Marta.
  • Wanted A rat catcher for the mill. Fair pay.
  • Lost A walking staff in the cabbage patch. Much missed.
  • For sale Six cabbages by the well. Ask for Marta.
  • Wanted Two strong backs for the harvest. No time wasters.
# frozen_string_literal: true

# One visitor's reading of the town notice board. Nothing here mentions
# motion. The channel renders what the board holds now, and the marks in
# the partials say how what is gone should leave.
class Recipes::Motion::MotionChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Notice = Recipes::Motion::Notice
  Board = Recipes::Motion::Board
  VIEWS = "app/recipes/motion"

  # The notice being written, and a line said once after it is pinned.
  Draft = Data.define(:kind, :body)
  Flash = Data.define(:id, :text)

  def build_graph
    @version = Hibiki::State.new(0)
    @kind = Hibiki::State.new(Board::ALL)
    @draft = Hibiki::State.new(nil)
    @flash = Hibiki::State.new(nil)
    @pins = 0

    @board = Hibiki::Derived.new { Board.new(kind: @kind.value) }
    kinds = Hibiki::Derived.new { @version.value; @board.value.kinds }
    notices = Hibiki::Derived.new { @version.value; @board.value.notices }

    # The filter and the rows are one fragment, so a notice torn down is
    # one render, and it is the first to follow the click.
    Hibiki::Effect.new do
      broadcast_morph target: "motion_board", partial: "#{VIEWS}/board",
                      locals: { kinds: kinds.value, kind: @board.value.kind, notices: notices.value }
    end

    Hibiki::Effect.new do
      broadcast_morph target: "motion_pin", partial: "#{VIEWS}/pin", locals: { draft: @draft.value, flash: @flash.value }
    end
  end

  def filter(data) = @kind.value = data["kind"].to_s

  def open
    @flash.value = nil
    @draft.value ||= draft(Notice::KINDS.first)
  end

  def cancel = @draft.value = nil

  # Another wording, of the kind asked for or the kind it has.
  def reword(data)
    return unless (current = @draft.value)

    kind = Notice::KINDS.include?(data["kind"]) ? data["kind"] : current.kind
    @draft.value = draft(kind, except: current.body)
  end

  def pin
    return unless (draft = @draft.value)

    Notice.pin(draft.kind, draft.body)
    @draft.value = nil
    @flash.value = Flash.new(id: @pins += 1, text: "Pinned under #{Board::NAMES[draft.kind]}.")
    @version.value += 1
  end

  # The row goes in this reply. The model's ping follows, finds the same
  # board and sends nothing.
  def tear(data)
    Notice.tear(data["id"])
    @version.value += 1
  end

  private

  def draft(kind, except: nil) = Draft.new(kind:, body: Notice.compose(kind, except:))

  def subscribed
    super
    return if subscription_rejected?

    # A write by anyone. The block runs on a cable thread, so it hops onto
    # the graph thread before touching a signal.
    stream_from Notice::CHANGED do
      graph_actor&.post { @version.value += 1 }
    end
  end
end

The board above is the town notice board. Press Tear down on a row to see the row slide off the board, followed by the rows below smoothly closing the gap. A leave like this is awkward in stock Rails: every change to the page is HTML from the server, and the swap takes the old row out before any animation can be applied. Hibiki provides a module you can import that makes the swap wait for the animation. The board is shared with every visitor and resets each night.

How it fits together

An island is the part of the page Hibiki looks after, here the whole board. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. The channel's public methods are its actions, the methods the page may call, and its states are values that remember who read them. Here the states that matter are which kind is shown and a version that goes up when anyone writes to the board.

An effect is a block that runs again when a state it read changes. The channel has two: one renders the kinds and the rows as one fragment, the other renders the box above the board that holds the form or the line. Neither mentions motion. The motion is in the browser, where hibiki-rails/motion is imported once and meets every render on its way in. This is what happens when you press Tear down:

  1. The button calls tear with the notice's id.
  2. The action deletes the notice and bumps the version. The effect that renders the board read the version, so it runs again and sends the board without that row.
  3. Before the new HTML goes in, the module finds the row, since the new HTML does not have it. It marks the row as leaving, waits for its transition to end, and then lets the swap run.

Animating an element as it leaves

Every change on this page comes from the server as new HTML, which the client swaps in for the old. An element that is new in that HTML can animate in with CSS alone, using @starting-style to set how it looks before it appears. Removing an element from the HTML is a different matter. The swap removes it straight away, and a removed element can't run a transition.

To animate removals, the swap has to wait until the element has finished leaving. Hibiki's motion module does the waiting, and you can add it with one import:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"
import "hibiki-rails/motion"

Before each swap, the module looks for marked elements that the new HTML doesn't have. It sets data-motion-leaving on each one, waits for its CSS transitions to finish, and then lets the swap go ahead. How the element leaves is up to your CSS (or you can use the transitions Hibiki provides).

To animate an element, give it the data-motion attribute, an id, and a class that styles the leave. The module matches elements by id, so the id has to stay the same from one render to the next.

<%# Leaves only when a click inside it removed it. %>
<li id="motion_notice_7" data-motion="own" class="hbk-slide-x">
  <div class="hbk-slide-x-body">…</div>
</li>

<%# Leaves whenever a render no longer has it. %>
<div id="motion_form" data-motion class="hbk-slide">
  <div class="hbk-slide-body">…</div>
</div>

<%# One class, and one number changed on this element only. %>
<p id="motion_flash_1" data-motion class="hbk-fly [--hbk-fly-y:-1rem]">Pinned under Lost.</p>

Animating only your own removals

On this board, a row can disappear in three ways: you tear it down, another visitor tears it down, or you pick a kind that hides it. When you tear a notice down, the slide shows you that your click worked. When a row goes for any other reason, the slide only gets in the way, most of all when a filter hides a dozen rows at once.

That's the reason the rows are marked data-motion="own". With own, the module animates an element only when the click that led to the new HTML came from inside that element. The Tear down button is inside its row, so a tear slides. The kind buttons sit above the rows, so a filter removes rows at once. Another visitor's tear started on their page, so on yours the row simply disappears.

The channel sends every tab the same HTML, so working out whose click it was happens in the browser. The island already remembers which button started each request, which is how a pressed button shows that it is busy. The module reads the same record.

The form and the "Pinned" message above the board use plain data-motion. Only your own clicks open and close them, so they always animate.

Animating an element as it enters

The channel updates the page with a morph. Instead of replacing the old HTML, a morph compares it with the new HTML and changes only what differs. If an element is in both, the morph edits it in place. The browser never sees it inserted, so its @starting-style never runs.

An element animates in only when it is new to the page, which means its id must be one the page didn't have a moment ago. The form slides open because nothing with its id was on the page before. The "Pinned" message puts a counter in its id. When you pin a second notice, the message gets a new id, so the browser treats it as a new element and it flies in again.

The own mark has no effect on entrances. Entrances run in CSS, and CSS animates every new element, whatever caused it to appear. If the rows had an entrance, picking All after a filter would slide every hidden row back onto the board. That is why the rows use hbk-slide-x, the only one of the six effects with a leave and no entrance.

The six effects Hibiki offers

The signwriter's yard

# frozen_string_literal: true

# The signwriter's yard: one sign, hung and taken down with the effect the
# visitor picks. The stage is sent over the channel itself, not a Turbo
# stream, and the same marks hold that swap too.
class Recipes::Motion::GalleryChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  VIEWS = "app/recipes/motion"

  EFFECTS = %w[slide slide-x fade fly scale blur].freeze

  # Every effect but the sideways slide also runs on the way in.
  ENTERS = EFFECTS - %w[slide-x]

  def build_graph
    @effect = Hibiki::State.new(EFFECTS.first)
    @shown = Hibiki::State.new(true)

    Hibiki::Effect.new do
      transmit({ html: render("stage", effect: @effect.value, shown: @shown.value) })
    end
  end

  # The sign's id has the effect in it, so a pick takes the old sign down
  # its own way and hangs the new one.
  def pick(data)
    return unless EFFECTS.include?(data["effect"])

    @effect.value = data["effect"]
    @shown.value = true
  end

  def toggle = @shown.value = !@shown.value

  private

  def render(partial, **locals) = ApplicationController.render(partial: "#{VIEWS}/#{partial}", locals:)
end

The yard above hangs a sign. Pick an effect, then press "Hang it or take it down" to see the animation. This island uses no Turbo Stream. Its channel sends the stage over the subscription with transmit, and the client swaps it in by id.

The effects are Tailwind utilities in one stylesheet, hibiki_motion.css (the Hibiki scaffold generator writes this file for you).

The same feature in stock Rails

A Turbo Stream removes its target at once, as any swap does, so the stock build needs the same wait. Turbo gives the place to put it, the turbo:before-stream-render event, and the rest is written by hand. Here is a sketch. It is not run here, and it leaves out the form, the partials for the kinds and the line, and the CSS, which is the same as above with a class in place of the attribute.

// Imported once from application.js. A Turbo Stream takes an element out
// before the browser has drawn it leaving, so every stream is met on its
// way in and held while what it removes plays its transition.

// Whose click was it? A broadcast does not say, so the page remembers:
// the row whose own form was submitted is stamped before the request goes.
document.addEventListener("turbo:submit-start", (event) => {
  event.target.closest("[data-leave]")?.setAttribute("data-mine", "")
})

// What a stream is about to remove: its target for a remove, and for an
// update or a replace the marked elements the new content does not have.
const leaving = (stream) => {
  if (stream.action === "remove") return stream.targetElements.filter((element) => element.matches("[data-leave]"))
  if (!["update", "replace"].includes(stream.action)) return []

  const kept = new Set([...stream.templateContent.querySelectorAll("[id]")].map((element) => element.id))
  return stream.targetElements
    .flatMap((target) => [...target.querySelectorAll("[data-leave]")])
    .filter((element) => !kept.has(element.id))
}

const mine = (element) => element.dataset.leave !== "own" || element.hasAttribute("data-mine")

// A transition that never ends must not hold the page for good.
const finished = (element) =>
  Promise.race([
    Promise.allSettled(element.getAnimations().map((animation) => animation.finished)),
    new Promise((resolve) => setTimeout(resolve, 1000))
  ])

// One queue, so a stream that is held is not overtaken by the next one.
let queue = Promise.resolve()

document.addEventListener("turbo:before-stream-render", (event) => {
  const stream = event.detail.newStream
  const render = event.detail.render

  event.detail.render = (element) =>
    (queue = queue.then(async () => {
      const going = leaving(stream).filter(mine)
      going.forEach((row) => row.classList.add("leaving"))
      await new Promise(requestAnimationFrame)
      await Promise.all(going.map(finished))
      await render(element)
    }))
})

Most of the listing is leave.js. It is the motion module again, written by hand: what a stream removes, a wait, a queue, a ceiling. The hard part is whose click it was. A broadcast is the same for everyone, so the page has to remember by itself, and it stamps the row when its form is submitted.

The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
An element is seen leaving A listener that works out what each stream removes, marks it and waits One import, and data-motion on the element
Only for the click that removed it A second listener stamps the row on submit, and the first reads the stamp data-motion="own"
A held render is not overtaken A queue of your own The module's queue
A transition that never ends A timeout of your own The one-second ceiling
The form shuts after a save The same listener, since the answer is a stream The same mark
An element is seen arriving CSS, @starting-style The same CSS
What the motion looks like CSS written by hand Six utilities in a file, or CSS written by hand
JavaScript written About forty lines, once for the app None

This is a narrower win than most on this site. The stock listener is written once and then serves every page, and an entrance costs the same in both. Turbo can also run the browser's View Transitions, but for a visit to a page, and a stream is not a visit. In the Hibiki version the listener is already written, and it can tell your click from everyone else's because the island was already keeping track.