Skip to content

Lesson 2 — Enabling Turbo Morph

The meta tags

Turbo Morph is enabled per-page via two meta tags in the <head>:

1
2
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">

turbo-refresh-method: morph tells Turbo to diff and patch instead of replacing the body. turbo-refresh-scroll: preserve keeps the page’s scroll position across refreshes — without this the page jumps to the top after every morph.

Adding meta tags in Phlex

The turbo-rails gem provides turbo_refreshes_with as a helper, but it uses content_for :head internally — a Rails ERB mechanism that doesn’t translate to Phlex. In Phlex you add the meta tags directly in the layout’s <head>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/views/layouts/app_layout.rb
class Views::Layouts::AppLayout < Components::Base
  # ...

  private

  def render_head
    head do
      title { "KanbanFlow" }
      meta(charset: "utf-8")
      meta(name: "viewport", content: "width=device-width,initial-scale=1")
      csrf_meta_tags
      csp_meta_tag
      meta(name: "turbo-refresh-method", content: "morph")
      meta(name: "turbo-refresh-scroll", content: "preserve")
      stylesheet_link_tag "tailwind", "data-turbo-track": "reload"
      stylesheet_link_tag "application", "data-turbo-track": "reload"
      javascript_importmap_tags
    end
  end
end

This enables morphing globally — every page in the app will morph on refresh. That’s the right default. If a specific page has issues with morphing (unusual JavaScript initialisation, third-party widgets that don’t survive DOM patching), you can disable it per-page by overriding turbo-refresh-method to replace — but that’s an edge case, not the norm.

How morph triggers

Morphing happens when Turbo detects a page refresh — a redirect back to the same URL. The controller stays completely conventional:

1
2
3
4
5
6
7
8
9
def create
  @board = current_user.boards.build(board_params)
  if @board.save
    redirect_to boards_path, status: :see_other
  else
    render Views::Boards::New.new(board: @board),
           status: :unprocessable_entity
  end
end

When redirect_to boards_path fires and the user is already on boards_path, Turbo intercepts the redirect, fetches the new HTML, and morphs the DOM. The new board appears in the list without a flash, a scroll jump, or any explicit wiring.

If the redirect goes to a different URL, Turbo Drive handles it normally — morphing only applies to same-URL refreshes.

Protecting elements from morphing

Sometimes you want an element to survive a morph untouched — an open dropdown, an expanded accordion, a search input that the user is actively typing in. Mark it with data-turbo-permanent:

1
2
3
div(id: "flash-messages", data: { turbo_permanent: true }) do
  # Flash messages won't be morphed away mid-display
end

The element must have a stable id — idiomorph uses it to match elements across the old and new DOM. Without a unique id, data-turbo-permanent has no effect.

This is particularly relevant for our ToastContainer — toasts that are mid-display shouldn’t be wiped by a morph:

1
2
3
4
5
6
7
8
9
# app/components/toast_container.rb
def view_template
  div(
    id:    "toast-container",
    class: "fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none",
    data:  { controller: "toast-container", turbo_permanent: true },
    aria:  { live: "polite", atomic: false }
  )
end