Fancier Toasts: Auto-close & Fading
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeYesterday, we cooked up a beautiful Toast notification system that's powered entirely with CSS and Symfony's normal flash system. Ok, and just a tiny bit of JavaScript to, boop, close it.
Today we're going to take this to the next level. I want these toasts to be amazing.
Adding Auto-Close
The first feature we'll add is auto-close: a classic in the toast world where the message graces our screen, then closes automatically after a few seconds. But I also want to keep our closeable controller reusable. There may be other parts of the site where we want to be able to close something... but not have it close itself automatically.
So, we need a way to activate the auto-close on a case-by-case basis. The way to pass info into a controller is via values. Add static values equals... and I'll invent a new one called autoClose, which will be a Number:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| static values = { | |
| autoClose: Number, | |
| }; | |
| // ... lines 7 - 18 | |
| } |
Next, add a connect() method. The idea is that if we have this.autoCloseValue - that's how you reference that - then... that's actually perfect! We'll use setTimeout to close after that many milliseconds:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| // ... lines 4 - 7 | |
| connect() { | |
| if (this.autoCloseValue) { | |
| setTimeout(() => { | |
| this.close(); | |
| }, this.autoCloseValue); | |
| } | |
| } | |
| // ... lines 15 - 18 | |
| } |
To finish, go to where we use this controller - _flashes.html.twig - to pass in the new autoClose value. We do that on the same element as the data-controller. Add data-closeable-auto-close-value equals and use 5,000 for 5 seconds:
| {% for message in app.flashes('success') %} | |
| <div | |
| // ... lines 3 - 6 | |
| data-closeable-auto-close-value="5000" | |
| > | |
| // ... lines 9 - 26 | |
| </div> | |
| {% endfor %} |
The format is data- the name of the controller, auto-close - that's the name of the value autoClose... but because we're in an HTML attribute, we use the "dash case" - then the word value equals and finally what we want to pass in. This format is harder to remember than just data-controller. But as you saw, if you have this Stimulus plugin for PhpStorm, it auto-completes it, which helps a lot.
Let's do this! Edit this record, save and 1, 2, 3, 4, 5... whoosh! It vanishes.
Auto-close Timer Bar
What's next on our quest for toast greatness? What about a timer bar that shows when the toast will close? A little bar that animates smaller and smaller, then finally disappears right as the toast auto-closes itself.
That sounds cool! Here's the plan: we're going to add an element down here then animate its width from 100% to 0% over those 5 seconds. To be able to find that element, inside the controller, we're going to use a target. Add static targets = ['timerbar']:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| // ... lines 4 - 7 | |
| static targets = ['timerbar'] | |
| // ... lines 9 - 26 | |
| } |
Then down in connect(), check for that: if this.hasTimerbarTarget, then this.timerbarTarget.style.width = 0:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| // ... lines 4 - 9 | |
| connect() { | |
| if (this.autoCloseValue) { | |
| // ... lines 12 - 15 | |
| if (this.hasTimerbarTarget) { | |
| // ... line 17 | |
| this.timerbarTarget.style.width = 0; | |
| // ... line 19 | |
| } | |
| } | |
| } | |
| // ... lines 23 - 26 | |
| } |
Assuming we've added a CSS transition to this element, that should animate the change from full width to 0. Oh, but one other detail: add a setTimeout and put this inside with a 10-millisecond delay:
| // ... lines 1 - 2 | |
| export default class extends Controller { | |
| // ... lines 4 - 9 | |
| connect() { | |
| if (this.autoCloseValue) { | |
| // ... lines 12 - 15 | |
| if (this.hasTimerbarTarget) { | |
| setTimeout(() => { | |
| this.timerbarTarget.style.width = 0; | |
| }, 10); | |
| } | |
| } | |
| } | |
| // ... lines 23 - 26 | |
| } |
This will allow the element to establish itself on the page with a full 100% width, before changing it to 0. This is a CSS transition trick. If you add or unhide an element and immediately change its width to 0... the CSS transition won't work. You need to let the element be on the page with 100% width for 1 animation frame, then change it.
Alrighty, with the stage set, time to add the timer bar. At the bottom of _flashes.html.twig, I'll paste it in:
| {% for message in app.flashes('success') %} | |
| <div | |
| // ... lines 3 - 7 | |
| > | |
| // ... lines 9 - 27 | |
| <div | |
| class="absolute bottom-0 left-0 h-1 bg-green-500 w-full transition-all duration-[5000ms] ease-linear" | |
| // ... line 30 | |
| ></div> | |
| </div> | |
| {% endfor %} |
This has an absolute position on the bottom, left of the parent with a height and green background. It also has an explicit width: that's the w-full. That's important for the transition.
To make this a target, add data-closeable-target="timerbar":
| {% for message in app.flashes('success') %} | |
| <div | |
| // ... lines 3 - 7 | |
| > | |
| // ... lines 9 - 27 | |
| <div | |
| class="absolute bottom-0 left-0 h-1 bg-green-500 w-full transition-all duration-[5000ms] ease-linear" | |
| data-closeable-target="timerbar" | |
| ></div> | |
| </div> | |
| {% endfor %} |
Ok! Let's see what this looks like. Hit edit, save, and it opens... but no animation. Let's do some debugging! No errors in my console. Ah... here's the problem: I should have listened to my editor: timerbarTarget.
Let's close this. Save and... that's what I want to see! And right as it gets to 0, boop, it closes.
Ok, I love how this looks. But our toast deserves one last detail: a graceful fade out... instead of this abrupt exit.
CSS Transition on Close
Fading things out is a bit tricky. You can use CSS transitions - and we will - to go from opacity 100 to 0. But then you also need some JavaScript to wait for that CSS transition to finish so that it can finally remove the element from the page or at least set its display to none.
To help us with this, we're going to use a library called stimulus-use. Stimulus Components - as we saw earlier - are a list of reusable stimulus controllers. stimulus-use is a group of behaviors that you can add to your Stimulus controllers. And there are a lot of interesting tools here.
The one we're going to use is called useTransition. So step one, let's get this installed. Run:
php bin/console importmap:require stimulus-use
Awesome! Then over in the controller, import that with import { useTransition } from 'stimulus-use':
| // ... line 1 | |
| import { useTransition } from 'stimulus-use'; | |
| // ... lines 3 - 36 |
To activate a behavior, you call it from connect(): useTransition(this) then pass any options you need. I'll paste a few in:
| // ... lines 1 - 3 | |
| export default class extends Controller { | |
| // ... lines 5 - 10 | |
| connect() { | |
| useTransition(this, { | |
| leaveActive: 'transition ease-in duration-200', | |
| leaveFrom: 'opacity-100', | |
| leaveTo: 'opacity-0', | |
| transitioned: true, | |
| }); | |
| // ... lines 18 - 29 | |
| } | |
| // ... lines 31 - 34 | |
| } |
Here's what this means. While this element is "leaving" or hiding, the library will add these three classes. This establishes that, in case any CSS properties change on this element, we want to have a 200 millisecond transition. The leaveFrom means that, at the moment it starts hiding, the library will give it this class: setting its opacity to 100. Then, one millisecond later, it will remove this class and add opacity-0. That change will trigger the 200 millisecond transition. Finally, transitioned true is a way for us to tell the library that we are starting in a visible state... because you can also use this library to start hidden and then transition in to make your element visible.
Now that we've initialized the behavior, our controller magically has two new methods: leave() and enter(). Down here in close(), instead of removing the element ourselves, say this.leave():
| // ... lines 1 - 3 | |
| export default class extends Controller { | |
| // ... lines 5 - 31 | |
| close() { | |
| this.leave(); | |
| } | |
| } |
Let's try this! Spin over, refresh, and save. Watch. Ah, it was quick, but that is exactly what we wanted! Our toast notification is polished and done.
Tomorrow's adventure: diving into the third and final part of Turbo: Streams. These are the Swiss army knife of Turbo, and will let us solve a whole new set of problems.
18 Comments
While time showing up the message is perfectly configurable (data-closeable-auto-close-value="2000") the time for the timebar is hardcoded.
`<div
What's you recommendation for solving that issue?
Hey @lid
An option would be to pass a Twig variable from the controller and generate the CSS class dynamically
{{ 'duration-['~time~'ms]' }}Cheers!
Thanks for your suggestion. Unfortunately, I would prefer a solution where configuration needs to be done at one position only. The notification is a component, a small one - at the client-side.
I managed to get it working this way:
In closeable_controller.js
Does something speak against it?
Thank you very much!
It requires a bit more JS code but if you plan to re-use that controller in many places I think it's a better approach than mine
Cheers!
Hi and thank you for this great tutorial.
I find that this "simple" approach to setting up flash messages is great, however, it seems to be limited in the case of different asset configurations. That is, if there is a "Backend" and a "Frontend" part, each with its own asset system, we have to put a data-turbo-track attribute for the page to completely reload.
So, if we emit a flash message from the backend for it to display on the frontend, there's a kind of double page refresh, and therefore the flash message is "lost" in between.
I don't know if there's a solution to manage this.
Hey @labfordev
Can you tell me why it would trigger a double-page refresh? I think that's more related to how your frontend and backend communicate with each other.
Actually, I'm using Vite.js to manage my assets, and it behaves and functions quite similarly to Webpack Encore. I have an asset configuration for the backend and an asset configuration for the frontend. Each with its own stimulus controllers, etc...
On the import of the JS entry file into my HTML, I added, for both the back and front parts, a data-turbo-track="reload" attribute so that the browser reloads the page when I switch from the backend to the frontend and vice versa.
When I launch a flash message from the backend, for example, or even more concretely from an event listener at the moment of user logout, it's impossible to display this flash message on the frontend (landing page following the logout).
It seems like turbo makes an ajax call to the frontend, and since it sees the data-turbo-track attribute, it reloads the page completely. Consequently, the flash message seems to have already been consumed in the meantime.
I'm not sure if this is very clear... Sorry for my very Frenchy English ^^
I think it closely resembles these issues:
Hey @labfordev!
Yes, I understand! Interesting - and that first issue, indeed, seems to be exactly what you're talking about! That's tricky. Honestly, I can't think of a great workaround :/. From our Symfony app, we have no idea that the first page request is about to be ignored and then a 2nd will follow... so we can't add any intelligence that would say "please don't remove the flash messages yet". The only ideas I have are:
A) Disable Turbo on forms submits where you might know this will happen. This, I admit, is not a super great solution.
B) In theory (?), you could check the
Referer. And if the current request is for the frontend, and theRefererwas for the backend, you could add some special code to "keep" the flashes in the container for an extra request. This... seems like it would work... but it also feels like there could be cases I'm not thinking about. To "keep" the flash messages for an extra request, you could... possibly create a custom Twig function -is_switching_areas()- that detects this situation. Then usepeekon the flashbag instead ofget().Sorry I can't be more helpful, but I am interested how you solve this :).
Cheers!
Hey @weaverryan!
Thank you for your great response! Indeed, it's quite a unique "problem" :p.
I'll try to look into it when I have a bit more time, following what you just said.
I'll get back to you if I find a solution.
Cheers!
Yea... those issues seem related to your problem. I don't know what's causing it. I'll see if Ryan can help us out
Thank you very much :)
Small typo in the script:
Should be autoClose?
Thanks for the mention @Rudi-T! All fixed now
Hi @weaverryan
can you please share, which Plugin(s) do you use for the autocomplete (on 1:04 by example) ?
Hey @Chris-56789 ,
Good question! That's a GitHub Copilot PhpStorm's plugin: https://github.com/features/copilot - a kind of AI autocompletion.
Cheers!
Thanks
Nice! I'd add a
data-turbo-temporaryattribute to the notification message itself so it's not added to the page cache if the user leaves the page before the notification is closed/removed (it would cause a weird behavior if they get back to the page again and the notification is in the page cache, they'd see it for a short moment as the live page version is being fetched and replaces the cached version). This means the notification will be removed from the document whenever the user goes to another page before the notification closes. To avoid losing the notification when a user leaves the page, you could make the notifications wrapper elementdata-turbo-permanentand give it an ID, but unfortunately, this has a weird side-effect of not adding new notifications to the wrapper element if you're doing that with regular flash+redirects (since the permanent elements won't be touched)... maybe that's also fixed with Turbo Streams?Hey @tonysm!
Absolutely! I do this too. I forgot to do it in the video, but added it later... and meant to add a note to this chapter, you just reminded me to add! :).
Hmm, interesting! Indeed, streams would fix this. I've never done it before, but instead of actually rendering the streams inside of the
<div id="flash-container">, you could render them right below/above this as a stream:This should work. It looks a bit odd - instead of physically rendering into
flash-container... you render an element that says "render this intoflash-container", but it should work. I'd love to know how it works in practice!Cheers!
"Houston: no signs of life"
Start the conversation!