turbo-refresh-animations
v0.0.1
Published
CSS class-based animations for Turbo page refresh morphs with form protection
Maintainers
Readme
Turbo Refresh Animations
Animates elements that enter, exit, or change during Turbo Page Refreshes.
Features:
- Opt-in animations via
data-turbo-refresh-animateattribute. - Animates entries, exits, and changes.
- Protect elements (especially forms) during same-page refresh morphs; the initiator still morphs.
- Customize animations via CSS classes.
- Works with importmaps, esbuild, webpack, or any bundler.
Table of Contents
- Installation
- Quick Start
- Data Attributes Reference
- How It Works
- Protecting Elements During Same-Page Refreshes
- Customization
- Refresh Deduping Notes
- Disabling the Turbo Progress Bar
- TODOs
- License
Installation
With importmaps (Rails 7+)
bin/importmap pin turbo-refresh-animationsWith npm/yarn
npm install turbo-refresh-animations
# or
yarn add turbo-refresh-animationsPeer dependency: @hotwired/turbo >= 8. If you're using Rails with turbo-rails, it's already included; otherwise install @hotwired/turbo alongside this package.
Quick Start
1. Import the library
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "turbo-refresh-animations"2. Add the CSS
Add CSS for the animation classes in your app's stylesheet. This package does not ship CSS. You can copy the example styles from the Example animations section or write your own.
3. Enable morphing in your layout
<%# app/views/layouts/application.html.erb %>
<head>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>4. Opt in elements for animations
Add data-turbo-refresh-animate and an id to elements you want to animate:
<%# app/views/items/_item.html.erb %>
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate>
<%= item.title %>
</div>Elements will animate when created, changed, or deleted during Turbo morphs.
Data Attributes Reference
| Attribute | Purpose |
|-----------|---------|
| id | Element identifier (required for enter/change/exit animations) |
| data-turbo-refresh-animate | Opt-in for animations (=""/present enables all, ="enter,exit" enables subset, ="none" disables) |
| data-turbo-refresh-enter="class" | Custom enter animation class (single class token; no spaces) |
| data-turbo-refresh-change="class" | Custom change animation class (single class token; no spaces) |
| data-turbo-refresh-exit="class" | Custom exit animation class (single class token; no spaces) |
| data-turbo-refresh-stream-permanent | Protect element during same-page refresh morphs (except initiator) |
| data-turbo-refresh-version | Override change detection (used instead of textContent, e.g. item.cache_key_with_version) |
How It Works
The library compares each element's "meaningful signature" before and after Turbo renders a page refresh morph. Elements with both an id and the data-turbo-refresh-animate attribute will be animated:
| Animation | Trigger | Default CSS class |
|-----------|---------|-------------------|
| Enter | New element added to DOM | turbo-refresh-enter |
| Change | Element text changes (or version changes) | turbo-refresh-change |
| Exit | Element removed from DOM | turbo-refresh-exit |
Change Detection
By default, change animations run only when an element's normalized textContent differs between the old and new page. This naturally ignores most "noise" that isn't user-visible (CSRF tokens, framework attributes, etc.).
Normalization collapses all whitespace to single spaces and trims leading/trailing whitespace.
For precise control (and to count non-text changes as meaningful), use data-turbo-refresh-version:
<div id="<%= dom_id(item) %>"
data-turbo-refresh-animate
data-turbo-refresh-version="<%= item.cache_key_with_version %>">
<%= item.title %>
<%= button_to "Delete", item, method: :delete %>
</div>When data-turbo-refresh-version is present, it's used instead of textContent to decide whether a change is meaningful. This is useful when:
- If you want to respect invisible changes like hidden inputs (e.g. CSRF tokens) or attributes.
- Elements include dynamic attributes from JavaScript frameworks.
- You want explicit control over what constitutes a "change".
Protecting Elements During Same-Page Refreshes
data-turbo-refresh-stream-permanent
When using broadcasts_refreshes_to (or any same-page refresh morph), any element with this attribute will be protected from morphing when the refresh comes from elsewhere (e.g., another user's refresh stream). This is useful for any element whose current DOM state you want to preserve during external refreshes.
Unlike Turbo's data-turbo-permanent, this protection is conditional: the element is allowed to morph when it contains the form submit or same-page link that initiated the refresh, so user-initiated updates still apply.
<div data-turbo-refresh-stream-permanent>
<!-- This element won't be morphed during refresh streams -->
</div>This protection does not require an id (unless you also want the element to participate in enter/change/exit animations).
The most common use case is forms. Without protection, a user typing in a form would lose their input whenever another user's action triggers a refresh stream:
<div id="new_item_form" data-turbo-refresh-stream-permanent>
<%= form_with model: item do |f| %>
<%= f.text_field :title %>
<%= f.submit "Add" %>
<% end %>
</div>Form-specific conveniences
Since forms are the most common use case, the library includes special handling:
Submitter's form still clears: When a user submits a form inside a protected element, that specific element is allowed to morph normally (so the form clears after submission via the redirect response). Other protected elements remain protected.
Same-page refreshes preserve state: Even during refresh morphs that stay on the same URL (e.g.,
redirect_toback to the current page), elements withdata-turbo-refresh-stream-permanentstay protected. This preserves user-created UI state like open edit forms. If a user clicks a same‑page link inside a protected element (e.g., "Cancel"), the library setsdata-turbo-action="replace"on that link so Turbo uses a refresh morph; the initiating element updates while other protected elements remain open.- For this behavior, “same page” means the same
origin + pathname + search(hash ignored). - Note: links to an anchor in the current document (e.g.
/lists/1#comments) are treated as in-page navigation and are not forced into a refresh morph.
- For this behavior, “same page” means the same
Flash protected elements on update
To show a visual indicator when a protected element's underlying data changes (e.g., another user edits the same item), add data-turbo-refresh-version:
<div id="<%= dom_id(item) %>"
data-turbo-refresh-stream-permanent
data-turbo-refresh-animate
data-turbo-refresh-version="<%= item.cache_key_with_version %>">
<%= form_with model: [item.list, item] do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<% end %>
</div>When the version changes during an external refresh, the element flashes with the change animation while keeping its current content protected.
Note: protected elements can temporarily be in a different "view state" than the server-rendered HTML (e.g., an open edit form vs a read-only item view). To avoid false positives, the library only flashes protected elements based on data-turbo-refresh-version from the incoming HTML. In practice, add data-turbo-refresh-version to all render variants of a given id if you want flashing to work reliably.
Customization
Custom animation classes per element
Use a different animation class for specific elements:
<div id="<%= dom_id(item) %>"
data-turbo-refresh-animate
data-turbo-refresh-enter="my-custom-enter"
data-turbo-refresh-exit="my-custom-exit">
<!-- Uses my-custom-enter and my-custom-exit instead of the default class names -->
</div>Enable specific animations
By default, data-turbo-refresh-animate enables all three animation types. Specify a comma-separated list to enable only certain types:
<%# Only animate exits (no enter or change) %>
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate="exit">
<%# Animate enter and exit (no change) %>
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate="enter,exit">
<%# All animations (default) %>
<div id="<%= dom_id(item) %>" data-turbo-refresh-animate>Options: enter, exit, change
To explicitly disable animations on an element, use data-turbo-refresh-animate="none" (or "false"). This can be useful when a helper emits the attribute automatically.
Define your own animations
You can override the default class names or use custom class names per element.
If you already have an existing class you want the library to use by default, CSS doesn't provide true "class aliasing", but you can get the same effect:
/* Apply the same rules to both selectors */
.turbo-refresh-enter,
.my-enter {
animation: myEnter 180ms ease-out;
}If you use Sass/SCSS, you can also do:
/* Make .turbo-refresh-enter reuse .my-enter rules */
.turbo-refresh-enter { @extend .my-enter; }Alternatively, set data-turbo-refresh-enter="my-enter" (or ...-change / ...-exit) on specific elements.
Exit animations can be implemented with CSS transitions (not just keyframes). The exit class should change a property with a non-zero transition duration (for example, opacity or transform). The element is removed after the transition ends (with a timeout fallback).
For predictable timing, use explicit transition properties (e.g., opacity, transform) instead of
transition-property: all.
Example: Background color flash
/* Enter - green background fade */
@keyframes bg-flash-enter {
from { background-color: #D1E7DD; }
to { background-color: inherit; }
}
.bg-flash-enter {
animation: bg-flash-enter 1.2s ease-out;
}
/* Change - yellow background fade */
@keyframes bg-flash-change {
from { background-color: #FFF3CD; }
to { background-color: inherit; }
}
.bg-flash-change {
animation: bg-flash-change 1.2s ease-out;
}
/* Exit - red background fade + opacity */
@keyframes bg-flash-exit {
from { background-color: #F8D7DA; opacity: 1; }
to { background-color: inherit; opacity: 0; }
}
.bg-flash-exit {
animation: bg-flash-exit 0.6s ease-out forwards;
}Example: Slide in/out
@keyframes slideInDown {
from {
transform: translate3d(0, -100%, 0);
visibility: visible;
}
to {
transform: translate3d(0, 0, 0);
}
}
.slideInDown {
animation: slideInDown 0.5s ease-out;
}
@keyframes slideOutUp {
from {
transform: translate3d(0, 0, 0);
}
to {
visibility: hidden;
transform: translate3d(0, -100%, 0);
}
}
.slideOutUp {
animation: slideOutUp 0.5s ease-out forwards;
}Masking slide animations
Transform-based slide animations can "ghost" over adjacent rows because they paint outside their own box.
To keep the motion contained, apply overflow: hidden on the animated element and animate a child:
<li data-turbo-refresh-animate
data-turbo-refresh-enter="slide-mask-enter"
data-turbo-refresh-exit="slide-mask-exit">
<div class="slide-mask-content">
<%= item.title %>
</div>
</li>.slide-mask-enter,
.slide-mask-exit {
overflow: hidden;
}
.slide-mask-enter > .slide-mask-content {
animation: slideInDown 0.5s ease-out;
}
.slide-mask-exit > .slide-mask-content {
animation: slideOutUp 0.5s ease-out forwards;
}Use custom classes per element:
<div id="<%= dom_id(item) %>"
data-turbo-refresh-animate
data-turbo-refresh-enter="slideInDown"
data-turbo-refresh-exit="slideOutUp">
<%= item.title %>
</div>Example animations
Copy/paste this example into your app's stylesheet:
/* Enter - fade in */
.turbo-refresh-enter {
animation: turbo-refresh-enter 300ms ease-out;
}
@keyframes turbo-refresh-enter {
from { opacity: 0; }
to { opacity: 1; }
}
/* Exit - fade out */
.turbo-refresh-exit {
animation: turbo-refresh-exit 300ms ease-out forwards;
}
@keyframes turbo-refresh-exit {
from { opacity: 1; }
to { opacity: 0; }
}
/* Change - yellow background flash */
.turbo-refresh-change {
animation: turbo-refresh-change 800ms ease-out;
}
@keyframes turbo-refresh-change {
from { background-color: #FFF3CD; }
to { background-color: inherit; }
}
@media (prefers-reduced-motion: reduce) {
.turbo-refresh-enter,
.turbo-refresh-change,
.turbo-refresh-exit {
animation-duration: 1ms;
animation-iteration-count: 1;
}
}Refresh Deduping Notes
You might be worried about the performance of using Turbo Refreshes so heavily, especially when paired with broadcasts from models. It's not as bad as you might think, because Turbo does two kinds of refresh deduping:
- Backend (Turbo Rails):
broadcasts_refreshes_tousesbroadcast_refresh_later_to, which is debounced per stream name +request_idon the current thread. Multiple refreshes in quick succession coalesce into the last one. This does not apply tobroadcast_refresh_to, and it is not a process-wide/global dedupe. - Frontend (Turbo Source): refresh stream actions are debounced in the session (default 150ms via
pageRefreshDebouncePeriod), and refreshes with arequest-idthat matches a recent client request are ignored. Therequest-idis set automatically when you useTurbo.fetch(it addsX-Turbo-Request-Id).
Disabling the Turbo Progress Bar
This library disables the Turbo progress bar during morph operations (but keeps it for regular navigation):
document.addEventListener("turbo:morph", () => {
Turbo.navigator.delegate.adapter.progressBar.hide()
})See hotwired/turbo#1221 for discussion on making this configurable in Turbo itself.
TODOs
- Expose a hook to set animation parameters before animations run (e.g., to measure
scrollHeightfor jQuery UI-style "push siblings" slide animations).
License
MIT
