I've been playing around with @starting-style
and had a thought; that combining it with transition-behavior
could aid in completing a purely CSS transition strategy for toggling display: none
or first render.
Dialog and popover both have display toggled via the browser.
Interruptible transitions > keyframe animations.
Try a full demo of what I'm gonna break down here on Codepen:
The result?
- Shared styles for showing/hiding elements with
display: none
- Custom entry and exit effects
- Interruptible motion
- Easy potential for staggering
It's almost, a unified orchestration of DOM elements using transitions. The only catch is you have to use display: none
to kick something off stage.
Caniuse? #
There's a handful of new goodies used in this post, here's caniuse links to all of it.
@starting-style
transition-behavior
<dialog>
Baseline 2022:modal
Baseline 2022[popover]
Baseline 2024:popover-open
Baseline 2024@layer
Baseline 2022[hidden]
Old School
Enter Stage #
Here's the original post's CSS and its few lines of glory:
* {
transition: opacity .5s ease-in;
@starting-style { opacity: 0 }
}
A crossfade entry transition occurs:
- on page load
- inserted into the page
- coming from
display: none
@starting-style
is describing the off stage starting place. I've said go from opacity: 0
to a natural resting place on the stage, which by default is opacity: 1
.
To aid in distinguishing between stage enter and exit, enter will start scaled up, so it scales down into the resting place. Like it floated in from above.
@starting-style {
opacity: 0;
scale: 1.1; 👈
}
Adding scale to the transition means we need to update the transition-property
list so it interpolates:
* {
transition:
opacity .5s ease-in,
scale .5s ease-in; 👈
@starting-style {
opacity: 0;
scale: 1.1;
}
}
Great, now things scale down and fade in when entering the stage 👍🏻
But, don't forget, be mindful, we're not just cross fading anymore.
* {
@media (prefers-reduced-motion: no-preference) { 👈
transition:
opacity .5s ease-in,
scale .5s ease-in;
}
@starting-style {
opacity: 0;
scale: 1.1;
}
}
Exit Stage #
This is where transition-behavior
comes in.
If you set display: none
on an element, none of the transitions have time to run before the browser immediately removes it from the stage. Adding transition-behavior
allow-discrete
allows you to specify a duration of time that should pass before applying the display: none
change.
* {
@media (prefers-reduced-motion: no-preference) {
transition:
opacity .5s ease-in,
scale .5s ease-in,
display .5s ease-in; 👈
transition-behavior: allow-discrete; 👈
}
@starting-style {
opacity: 0;
scale: 1.1;
}
}
Now, the browser won't instantly hide elements when they're set to display none, it will wait .5s
, which is exactly how long our transition runs. Cool.
But we're missing something.. oh yes, the exit stage styles! Something needs to trigger a state we can style with opacity
, scale
and display
.
I chose to lean into the browser provided attribute that sets display: none
, the [hidden]
attribute!
* {
@media (prefers-reduced-motion: no-preference) {
transition:
opacity .5s ease-in,
scale .5s ease-in,
display .5s ease-in;
transition-behavior: allow-discrete;
}
@starting-style {
opacity: 0;
scale: 1.1;
}
&[hidden] { 👈
opacity: 0;
scale: .9;
display: none !important;
transition-duration: .4s;
transition-timing-function: ease-out;
}
}
So when any element has been given the [hidden]
attribute, we describe the off stage styles. I've made the duration .4s
with an ease-out
effect because I think it's nice when exit animations happen slightly faster than entry animations.
Notice !important added. The browser provides that automatically for the attribute, but it's specificity is weak. If elements have any custom display type set, none isn't applied and the effect is lost. This also means you could employ your own type of flag here.
You can try out this effect in the demo when using any of the "Toggle [hidden]" buttons.
Dialog and popover #
We can add onto the &[hidden]
selector and then the <dialog>
and [popover]
elements can leave the stage the same way as everyone else.
* {
@media (prefers-reduced-motion: no-preference) {
transition:
opacity .5s ease-in,
scale .5s ease-in,
display .5s ease-in;
transition-behavior: allow-discrete;
}
@starting-style {
opacity: 0;
scale: 1.1;
}
&[hidden],
dialog:not(:modal), 👈
&[popover]:not(:popover-open) { 👈
opacity: 0;
scale: .9;
display: none !important;
transition-duration: .4s;
transition-timing-function: ease-out;
}
}
For <dialog>
, I'm using the :modal
pseudo class for knowing when the dialog is hidden.
For [popover]
, I'm using the :popover-open
pseudo class for knowing when a popover is hidden.
You can try out this effect in the demo when using the dialog or popover show buttons.
A little @layer flavor #
With cascade layers you can make an anonymous layer. I like to think of it as a 2 part feature, which works nicely for this wildly *
applied entry and exit transition style.
- should be easy to override
- shouldn't be too much noise in devtools
@layer {
…
}
By wrapping the entire nested set of styles we've made so far, we'll be accomplishing #1 and #2 because:
- a demoted anonymous layer is weaker than unlayered styles or can be imported into another layer the author knows is a weaker layer
- the layer drops the styles in the styles pane underneath the other styles
@layer { 👈
* {
@media (prefers-reduced-motion: no-preference) {
transition:
opacity .5s ease-in,
scale .5s ease-in,
display .5s ease-in;
transition-behavior: allow-discrete;
}
@starting-style {
opacity: 0;
scale: 1.1;
}
&[hidden],
dialog:not(:modal),
&[popover]:not(:popover-open) {
opacity: 0;
scale: .9;
display: none !important;
transition-duration: .4s;
transition-timing-function: ease-out;
}
}
}
I like the effect, I also like cascade layers. But you could def get away without this @layer
if you wanted.
Transitions complete? #
So the items have transitioned to display none, rad, but now you wanna clean up the node.
function onTransitionsEnded(node) {
return Promise.allSettled(
node.getAnimations().map(animation =>
animation.finished))
}
Using it is pretty nice:
async () => {
node.hidden = true
await onTransitionsEnded(node)
node.remove()
}
Now cleanup is nice and easy.
You can try out this effect in the demo when using the "remove" button.
Conclusion #
Some of this CSS looks a bit voodoo, but I think the journey to the end snippet makes sense.
I also think it'd make sense to scope it to a class or something, there's probably some scenarios where the results aren't desirable for every element. But conceptually targetting everything sure is fun.
View transitions are a good option for some of what this solves, but they aren't interruptible. Also not as easy to integrate with dialog and popovers.
Transitions are rad, and for dialog and popover is definitely valuable for the user to be able to interrupt any effects.
Thanks for following along with this wierd CSS thought!
Update #1:
If you want to transition the <dialog>
::backdrop
you're going to need to add some styles. You may also need to transition overlay
depending on what you're after. For a less experimental snippet that does fully transition dialogs and popovers, see this Codepen and steal the CSS from there.
Update #2:
My function for knowing when all the transitions have finished has been updated. It's now 100% reliable and using getAnimations()
. I'd tried this in my testing and found the array to be empty. BUT, later found out that it's because none of the transitions are running. Once they're kicked off, getAnimations()
returns each transition PLUS a .finished
promise.