Smooth UI Animations on Server-Rendered HTML

Adrien Siami - Paris.rb - April 2026

Links

👋 I'm Adrien

Adrien Siami

  • Rails fan since 2010
  • Turbo in prod since beta 🤘


Socials: @Intrepidd

Server-rendered HTML is great

  • Fast initial load
  • Great for SEO
  • No javascript madness
  • SPA-like behaviour with Turbo

... but it doesn't feel great

  • Page jumps back to top on updates
  • Input focus is lost
  • No smooth transitions
  • Feels "old school"

The Goal

Make server-rendered apps feel like modern SPAs
...at a fraction of the complexity

Let's take a simple todo app

A boring controller

            
              def create
                @todo = Todo.new(todo_params)
                if @todo.save
                  redirect_to todos_path
                else
                  redirect_to todos_path,
                    alert: @todo.errors.full_messages.to_sentence
                end
              end

              def toggle
                @todo.update!(completed: !@todo.completed)
                redirect_to todos_path
              end

              def destroy
                @todo.destroy!
                redirect_to todos_path
              end
            
          

Step 1: Turbo Morphing

One line of code

            
              <%# app/views/todos/index.html.erb %>
              <% turbo_refreshes_with method: :morph, scroll: :preserve %>
            
          

What it does

            
              <% turbo_refreshes_with method: :morph, scroll: :preserve %>
            
          
  • Turbo fetches the new page
  • Idiomorph diffs old vs new HTML
  • Only patches what changed
  • Focus & scroll position preserved ✨

Step 2: View Transitions API

The basic idea

            
              document.startViewTransition(() => {
                // Mutate the DOM however you want
                element.textContent = "New content";
                someList.appendChild(newItem);
                otherElement.remove();
              });
            
          

What the browser does

  1. Takes a screenshot of current state
  2. Runs your callback (DOM mutations)
  3. Captures the new state
  4. Animates from old → new


Default: nice crossfade. No CSS, no JS animation logic.

view-transition-name

            
              
Hello

Browser tracks this element across DOM mutations
Animates movement automatically!

view-transition-class

            
              
...
...
            
              ::view-transition-group(.card) {
                animation-duration: 0.3s;
              }
            
          

Example

Browser Support (March 2026)

✅ Chrome, Edge, Firefox, Safari


We're in the future! 🎉

Turbo + View Transitions

One meta tag

            
              <%# app/views/layouts/application.html.erb %>
              
            
          

That's it!

            
              
            
          
  • Turbo wraps every navigation in startViewTransition()
  • No custom JavaScript needed
  • Graceful fallback if unsupported

Back to our todo app

            
              <%# app/views/todos/_todo.html.erb %>
              <%= tag.div id: dom_id(todo),
                  style: "view-transition-name: #{dom_id(todo)};
                          view-transition-class: todo" do %>
                <%# ... %>
              <% end %>
            
          

The Philosophy

Write HTML to describe what the state should look like,
let the technology handle the transition.

Step 3: Optimistic UI with Stimulus

The last issue

We still wait for the server response
before seeing the visual change

The trick

Apply checked style as soon as the todo is clicked

  • do it smart: use aria-checked on the todo
  • target that to display the checked styling (strikethrough etc)
  • toggle that attribute with Stimulus

In Tailwind

            
              
                <%= todo.name %>
              
            
          

Generic Stimulus Controller

            
              import { Controller } from "@hotwired/stimulus"

              export default class extends Controller {
                static values = {
                  attribute: String
                }

                toggle(event) {
                  const currentValue =
                    this.element.getAttribute(this.attributeValue)
                  const isTrue = currentValue === "true"
                  this.element.setAttribute(
                    this.attributeValue, (!isTrue).toString()
                  )
                }
              }
            
          

Plug it in

            
              <%= tag.div id: dom_id(todo),
                  data: {
                    controller: "toggle-attribute",
                    toggle_attribute_attribute_value: "aria-checked",
                  } do %>

                <%= button_to toggle_todo_path(todo),
                    data: {
                      action: "click->toggle-attribute#toggle"
                    } do %>
                    <%# ... %>
                <% end %>
              <% end %>
            
          

Conclusion

The combo

  • Turbo morphing → preserve state & scroll
  • View Transitions → smooth animations
  • Stimulus → optimistic UI


= Modern SPA feel, fraction of the complexity

There is a way out of the SPA madness!

Browsers are catching up:
View Transitions, Dialogs, Popovers,
Container Queries, Web Components...

Thanks !

Presentation & all links available here :
QR code