Frame Loading Animations

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

With Turbo Drive, when we click a link or submit a form, and that takes longer than 500 milliseconds to load, we get a loading animation on the top of the page... which we don't see here because this is all loading fast, but we saw it earlier. It's a built-in, global loading indicator that we don't even need to think about.

But the same thing does not happen for Turbo frames. When you click the read more link, that loads pretty fast, but there is a slight delay when nothing happens. And if clicking this loaded a heavier page.... it might not load so fast. It's pretty normal to add a loading indicator in situations like this. Can we add one with Turbo frames?

The "busy" Attribute

Sure! And we already have what we need. Head over to src/Controller/CartController.php. In _cartFeaturedProduct(), let's sleep for three seconds to fake a slow page.

Back at the browser, inspect this turbo-frame and make sure it's highlighted. Watch the element closely when I refresh. Look! It has a busy attribute! Yup, whenever a turbo-frame is loading, it gets this attribute. If we click the "read more" link, we'll see it again.

This simple attribute makes it possible to add all sorts of loading indicators. For example, we could create two classes to help us hide or show an element during loading.

Hiding / Showing Elements During Loading

Open up templates/cart/_featuredSidebar.html.twig. Ok, let's pretend that we want to hide the "read more" link once we click it. Add class="" and let's invent a new class called frame-loading-hide. We'll add the CSS for this in a minute. After this, add a <span> and give it a different, new, class - frame-loading-show - that will cause this element to only show when loading. Also give this fas fa-spinner fa-spin to render a FontAwesome loading animation.

... lines 1 - 17
{% if showDescription %}
{{ featuredProduct.description }}
{% else %}
{{ featuredProduct.description|u.truncate(25)|trim }}...
<a
data-turbo-frame="cart-sidebar"
class="frame-loading-hide"
href="{{ path('_app_cart_product_featured', {
description: true,
}) }}">(read more)</a>
<span class="frame-loading-show fas fa-spinner fa-spin"></span>
{% endif %}
... lines 31 - 36

To add styling for these, open up assets/styles/app.css. Target the busy attribute with turbo-frame[busy]. So if there's a turbo-frame element that has a busy attribute, then for any elements inside with a frame-loading-hide class, display: none.

For the other class - the frame-loading-show - we want this to hide by default and then only show when loading. First, to hide it, copy the CSS selector, paste, make it apply to all turbo-frame elements, and look for the frame-loading-show class. So, hide these by default.

And, whoops! That jumped a bit. Anyways, below this, override that: inside a turbo-frame[busy] element, if you have a frame-loading-show class, display: inline-block.

... lines 1 - 18
turbo-frame[busy] .frame-loading-hide, turbo-frame .frame-loading-show {
display: none;
}
turbo-frame[busy] .frame-loading-show {
display: inline-block;
}
... lines 25 - 180

It's a little complicated, but that should get the job done and give us two classes that we can reuse across our site. Let's try it! Find your browser, refresh and... perfect! You can already see that my FontAwesome icon is not showing up because it's hidden by default. Now click this link. Beautiful!

Loading Opacity

And... that's it! You can leverage this busy attribute to do whatever you want. For example, we can give every frame on our site loading behavior by lowering their opacity. This is pretty easy. Copy the turbo-frame from above to say that any turbo-frame with a busy attribute should have opacity set to .2. That's an extreme level - but it'll be easy to see.

... lines 1 - 18
turbo-frame[busy] {
opacity: .2;
}
... lines 22 - 183

When we refresh now, we should even see this during the initial load. And... we do! When we click the "read more" link... uh... hmm. I did not see the lower opacity. That's weird. Inspect the element... and hack a busy attribute on the end of this.

turbo-frame is an Inline Element by Default

Hmm. When I do this, our browser does see the correct opacity CSS... it just doesn't seem to be doing anything! Hover over the element... let me scroll up a bit. Check it out: it has no height! I see the arrow in the upper left... but it's not highlighting the element. You'd expect it to go around the element like this... but it's not!

So this is interesting. The problem is that <turbo-frame> is a custom HTML element. And by default, your browser renders it as an inline element. You can see this over in the computed CSS: it has display: inline. And so, when you put block elements inside of it, it just... doesn't expand in the way you'd expect it to. That's why it appears to have no height. And that's why nothing gets the lower opacity.

To fix this, we can make this element display: block. As soon as I hack this in, the opacity does take effect. To make this work everywhere, we can make our turbo-frames display: block by default with turbo-frame, display: block.

... lines 1 - 18
turbo-frame {
display: block;
}
... lines 22 - 186

Try it now. The opacity on loading still works and when we click... that works too!

So now that this looks spectacular, let's go and make the opacity a little less @dramatic... and over in CartController, take out the sleep.

... lines 1 - 18
turbo-frame {
display: block;
}
turbo-frame[busy] {
opacity: .7;
}
turbo-frame[busy] .frame-loading-hide, turbo-frame .frame-loading-show {
display: none;
}
turbo-frame[busy] .frame-loading-show {
display: inline-block;
}
... lines 31 - 186

Let's go play with the page. That feels much more natural.

Fixing the Checkout Page

Before we keep going and doing other cool Turbo frame stuff, we accidentally broke the checkout page! It... was my fault.

Variable showDescription does not exist

Coming from _featuredSidebar.html.twig. The template for this page lives at templates/checkout/checkout.html.twig.

... lines 1 - 10
<div class="row">
<aside class="col-12 col-lg-4">
{% if featuredProduct %}
{{ include('cart/_featuredSidebar.html.twig') }}
{% endif %}
</aside>
... lines 18 - 66

Ooooh. This page also has a featured product sidebar... and it is still using the include directly. When we added our new showDescription variable, I didn't realize this was being included directly and... well... now things are mad.

We could fix this by passing in the variable... or even coding defensively inside _featuredSidebar.html.twig. But, pfff. We have a working, lazy Turbo Frame! So let's just use that! In cart.html.twig, steal the lazy frame and paste it inside checkout.html.twig.

... lines 1 - 10
<div class="row">
<aside class="col-12 col-lg-4">
<turbo-frame id="cart-sidebar" src="{{ path('_app_cart_product_featured') }}" target="_top">
Loading...
</turbo-frame>
</aside>
... lines 18 - 66

Celebrate by opening up the controller for this page, which is CheckoutController, and removing some variables that we don't need anymore: addToCartForm and featuredProduct... which means we can delete both variables... and we don't need to inject this argument.

... lines 1 - 20
/**
* @Route("/checkout", name="app_checkout")
*/
public function checkout(Request $request, CartStorage $cartStorage, EntityManagerInterface $entityManager, SessionInterface $session): Response
{
$checkoutForm = $this->createForm(CheckoutFormType::class);
$checkoutForm->handleRequest($request);
if ($checkoutForm->isSubmitted() && $checkoutForm->isValid()) {
/** @var Purchase $purchase */
$purchase = $checkoutForm->getData();
$purchase->addItemsFromCart($cartStorage->getCart());
$entityManager->persist($purchase);
$entityManager->flush();
$session->set('purchase_id', $purchase->getId());
$cartStorage->clearCart();
return $this->redirectToRoute('app_confirmation');
}
return $this->renderForm('checkout/checkout.html.twig', [
'checkoutForm' => $checkoutForm,
]);
}
... lines 47 - 65

Cool! Refresh now and... all good. The "read more", of course, even works here because Turbo & Stimulus are awesome.

Next: below each product, if you're logged in, users can post a review. We can make this a bit more awesome by leveraging a turbo frame.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}