Premise
Asynchronously loading content via Turbo Frames sometimes comes with a user experience degradation because the frame doesn’t advertise its busy status, and/or becomes unresponsive.
Starting Point
We start with a quite simplistic page that has a bullet point navigation and a Turbo Frame for the contents:
<ul>
<li><a href="/page1" data-turbo-frame="async-loader">Load Page 1</a></li>
<li><a href="/page2" data-turbo-frame="async-loader">Load Page 2</a></li>
</ul>
<turbo-frame id="async-loader" src="/page1">
>
</turbo-frame>
Observe that the links in the navigation point to the async-loader Turbo Frame, i.e. its src attribute is targeted.
The NodeJS backend is set up so that it returns page1.html and page2.html respectively:
https://stackblitz.com/edit/turbo-frames-loading-spinner?file=index.js%3AL12
These two pages contain just the Turbo frame to exchange:
<!-- page1.html -->
<turbo-frame id="async-loader"><span>Page 1 took 1 sec to load<span></turbo-frame>
<!-- page2.html -->
<turbo-frame id="async-loader">Page 2 took 2 sec to load</turbo-frame>
Challenge
To implement loading/busy feedback, use the <template> fragment supplied in the index page:
<template data-frame-spinner-target="placeholder"
>↻ Spinner, <em>can be any</em> <strong>HTML</strong></template
>
https://stackblitz.com/edit/turbo-frames-loading-spinner?file=index.html%3AL46
The crucial point is to clone this fragment and insert it when the busy attribute appears, and remove it afterwards. For this we make use of a MutationObserver in a Stimulus controller. Thankfully useMutation from the Stimulus Use project makes this trivial. Insert the HTML swapping logic here:
Here’s a preview of the end result:

Teaser
There’s a way to make this work only with CSS if you don’t need any HTML in the “loading” display!


