Transitioning Top-Layer Entries And The Display Property In CSS
@starting-style
and transition-behavior
— two properties that are absolutely welcome additions to your everyday work with CSS animations.Animating from and to display: none;
was something we could only achieve with JavaScript to change classes or create other hacks. The reason why we couldn’t do this in CSS is explained in the new CSS Transitions Level 2 specification:
“In Level 1 of this specification, transitions can only start during a style change event for elements that have a defined before-change style established by the previous style change event. That means a transition could not be started on an element that was not being rendered for the previous style change event.”
In simple terms, this means that we couldn’t start a transition on an element that is hidden or that has just been created.
What Does transition-behavior: allow-discrete
Do?
allow-discrete
is a bit of a strange name for a CSS property value, right? We are going on about transitioning display: none
, so why isn’t this named transition-behavior: allow-display
instead? The reason is that this does a bit more than handling the CSS display
property, as there are other “discrete” properties in CSS. A simple rule of thumb is that discrete properties do not transition but usually flip right away between two states. Other examples of discrete properties are visibility
and mix-blend-mode
. I’ll include an example of these at the end of this article.
To summarise, setting the transition-behavior
property to allow-discrete
allows us to tell the browser it can swap the values of a discrete property (e.g., display
, visibility
, and mix-blend-mode
) at the 50% mark instead of the 0% mark of a transition.
What Does @starting-style
Do?
The @starting-style
rule defines the styles of an element right before it is rendered to the page. This is highly needed in combination with transition-behavior
and this is why:
When an item is added to the DOM or is initially set to display: none
, it needs some sort of “starting style” from which it needs to transition. To take the example further, popovers and dialog elements are added to a top layer which is a layer that is outside of your document flow, you can kind of look at it as a sibling of the <html>
element in your page’s structure. Now, when opening this dialog or popover, they get created inside that top layer, so they don’t have any styles to start transitioning from, which is why we set @starting-style
. Don’t worry if all of this sounds a bit confusing. The demos might make it more clearly. The important thing to know is that we can give the browser something to start the animation with since it otherwise has nothing to animate from.
A Note On Browser Support
At the moment of writing, the transition-behavior
is available in Chrome, Edge, Safari, and Firefox. It’s the same for @starting-style
, but Firefox currently does not support animating from display: none
. But remember that everything in this article can be perfectly used as a progressive enhancement.
Now that we have the theory of all this behind us, let’s get practical. I’ll be covering three use cases in this article:
- Animating from and to
display: none
in the DOM. - Animating dialogs and popovers entering and exiting the top layer.
- More “discrete properties” we can handle.
Animating From And To display: none
In The DOM
For the first example, let’s take a look at @starting-style
alone. I created this demo purely to explain the magic. Imagine you want two buttons on a page to add or remove list items inside of an unordered list.
This could be your starting HTML:
<button type="button" class="btn-add">
Add item
</button>
<button type="button" class="btn-remove">
Remove item
</button>
<ul role="list"></ul>
Next, we add actions that add or remove those list items. This can be any method of your choosing, but for demo purposes, I quickly wrote a bit of JavaScript for it:
document.addEventListener("DOMContentLoaded", () => {
const addButton = document.querySelector(".btn-add");
const removeButton = document.querySelector(".btn-remove");
const list = document.querySelector('ul[role="list"]');
addButton.addEventListener("click", () => {
const newItem = document.createElement("li");
list.appendChild(newItem);
});
removeButton.addEventListener("click", () => {
if (list.lastElementChild) {
list.lastElementChild.classList.add("removing");
setTimeout(() => {
list.removeChild(list.lastElementChild);
}, 200);
}
});
});
When clicking the addButton
, an empty list item gets created inside of the unordered list. When clicking the removeButton
, the last item gets a new .removing
class and finally gets taken out of the DOM after 200ms.
With this in place, we can write some CSS for our items to animate the removing part:
ul {
li {
transition: opacity 0.2s, transform 0.2s;
&.removing {
opacity: 0;
transform: translate(0, 50%);
}
}
}
This is great! Our .removing
animation is already looking perfect, but what we were looking for here was a way to animate the entry of items coming inside of our DOM. For this, we will need to define those starting styles, as well as the final state of our list items.
First, let’s update the CSS to have the final state inside of that list item:
ul {
li {
opacity: 1;
transform: translate(0, 0);
transition: opacity 0.2s, transform 0.2s;
&.removing {
opacity: 0;
transform: translate(0, 50%);
}
}
}
Not much has changed, but now it’s up to us to let the browser know what the starting styles should be. We could set this the same way we did the .removing
styles like so:
ul {
li {
opacity: 1;
transform: translate(0, 0);
transition: opacity 0.2s, transform 0.2s;
@starting-style {
opacity: 0;
transform: translate(0, 50%);
}
&.removing {
opacity: 0;
transform: translate(0, 50%);
}
}
}
Now we’ve let the browser know that the @starting-style
should include zero opacity and be slightly nudged to the bottom using a transform
. The final result is something like this:
But we don’t need to stop there! We could use different animations for entering and exiting. We could, for example, update our starting style to the following:
@starting-style {
opacity: 0;
transform: translate(0, -50%);
}
Doing this, the items will enter from the top and exit to the bottom. See the full example in this CodePen:
When To Use transition-behavior: allow-discrete
In the previous example, we added and removed items from our DOM. In the next demo, we will show and hide items using the CSS display
property. The basic setup is pretty much the same, except we will add eight list items to our DOM with the .hidden
class attached to it:
<button type="button" class="btn-add">
Show item
</button>
<button type="button" class="btn-remove">
Hide item
</button>
<ul role="list">
<li class="hidden"></li>
<li class="hidden"></li>
<li class="hidden"></li>
<li class="hidden"></li>
<li class="hidden"></li>
<li class="hidden"></li>
<li class="hidden"></li>
<li class="hidden"></li>
</ul>
Once again, for demo purposes, I added a bit of JavaScript that, this time, removes the .hidden
class of the next item when clicking the addButton
and adds the hidden
class back when clicking the removeButton
:
document.addEventListener("DOMContentLoaded", () => {
const addButton = document.querySelector(".btn-add");
const removeButton = document.querySelector(".btn-remove");
const listItems = document.querySelectorAll('ul[role="list"] li');
let activeCount = 0;
addButton.addEventListener("click", () => {
if (activeCount < listItems.length) {
listItems[activeCount].classList.remove("hidden");
activeCount++;
}
});
removeButton.addEventListener("click", () => {
if (activeCount > 0) {
activeCount--;
listItems[activeCount].classList.add("hidden");
}
});
});
Let’s put together everything we learned so far, add a @starting-style
to our items, and do the basic setup in CSS:
ul {
li {
display: block;
opacity: 1;
transform: translate(0, 0);
transition: opacity 0.2s, transform 0.2s;
@starting-style {
opacity: 0;
transform: translate(0, -50%);
}
&.hidden {
display: none;
opacity: 0;
transform: translate(0, 50%);
}
}
}
This time, we have added the .hidden
class, set it to display: none
, and added the same opacity
and transform
declarations as we previously did with the .removing
class in the last example. As you might expect, we get a nice fade-in for our items, but removing them is still very abrupt as we set our items directly to display: none
.
This is where the transition-behavior
property comes into play. To break it down a bit more, let’s remove the transition
property shorthand of our previous CSS and open it up a bit:
ul {
li {
display: block;
opacity: 1;
transform: translate(0, 0);
transition-property: opacity, transform;
transition-duration: 0.2s;
}
}
All that is left to do is transition the display
property and set the transition-behavior
property to allow-discrete
:
ul {
li {
display: block;
opacity: 1;
transform: translate(0, 0);
transition-property: opacity, transform, display;
transition-duration: 0.2s;
transition-behavior: allow-discrete;
/* etc. */
}
}
We are now animating the element from display: none
, and the result is exactly as we wanted it:
We can use the transition
shorthand property to make our code a little less verbose:
transition: opacity 0.2s, transform 0.2s, display 0.2s allow-discrete;
You can add allow-discrete
in there. But if you do, take note that if you declare a shorthand transition after transition-behavior
, it will be overruled. So, instead of this:
transition-behavior: allow-discrete;
transition: opacity 0.2s, transform 0.2s, display 0.2s;
…we want to declare transition-behavior
after the transition
shorthand:
transition: opacity 0.2s, transform 0.2s, display 0.2s;
transition-behavior: allow-discrete;
Otherwise, the transition
shorthand property overrides transition-behavior
.
Animating Dialogs And Popovers Entering And Exiting The Top Layer
Let’s add a few use cases with dialogs and popovers. Dialogs and popovers are good examples because they get added to the top layer when opening them.
What Is That Top Layer?
We’ve already likened the “top layer” to a sibling of the <html>
element, but you might also think of it as a special layer that sits above everything else on a web page. It’s like a transparent sheet that you can place over a drawing. Anything you draw on that sheet will be visible on top of the original drawing.
The original drawing, in this example, is the DOM. This means that the top layer is out of the document flow, which provides us with a few benefits. For example, as I stated before, dialogs and popovers are added to this top layer, and that makes perfect sense because they should always be on top of everything else. No more z-index: 9999
!
But it’s more than that:
z-index
is irrelevant: Elements on the top layer are always on top, regardless of theirz-index
value.- DOM hierarchy doesn’t matter: An element’s position in the DOM doesn’t affect its stacking order on the top layer.
- Backdrops: We get access to a new
::backdrop
pseudo-element that lets us style the area between the top layer and the DOM beneath it.
Hopefully, you are starting to understand the importance of the top layer and how we can transition elements in and out of it as we would with popovers and dialogues.
Transitioning The Dialog Element In The Top Layer
The following HTML contains a button that opens a <dialog>
element, and that <dialog>
element contains another button that closes the <dialog>
. So, we have one button that opens the <dialog>
and one that closes it.
<button class="open-dialog" data-target="my-modal">Show dialog</button>
<dialog id="my-modal">
<p>Hi, there!</p>
<button class="outline close-dialog" data-target="my-modal">
close
</button>
</dialog>
A lot is happening in HTML with invoker commands that will make the following step a bit easier, but for now, let’s add a bit of JavaScript to make this modal actually work:
// Get all open dialog buttons.
const openButtons = document.querySelectorAll(".open-dialog");
// Get all close dialog buttons.
const closeButtons = document.querySelectorAll(".close-dialog");
// Add click event listeners to open buttons.
openButtons.forEach((button) =< {
button.addEventListener("click", () =< {
const targetId = button.getAttribute("data-target");
const dialog = document.getElementById(targetId);
if (dialog) {
dialog.showModal();
}
});
});
// Add click event listeners to close buttons.
closeButtons.forEach((button) =< {
button.addEventListener("click", () =< {
const targetId = button.getAttribute("data-target");
const dialog = document.getElementById(targetId);
if (dialog) {
dialog.close();
}
});
});
I’m using the following styles as a starting point. Notice how I’m styling the ::backdrop
as an added bonus!
dialog {
padding: 30px;
width: 100%;
max-width: 600px;
background: #fff;
border-radius: 8px;
border: 0;
box-shadow:
rgba(0, 0, 0, 0.3) 0px 19px 38px,
rgba(0, 0, 0, 0.22) 0px 15px 12px;
&::backdrop {
background-image: linear-gradient(
45deg in oklab,
oklch(80% 0.4 222) 0%,
oklch(35% 0.5 313) 100%
);
}
}
This results in a pretty hard transition for the entry, meaning it’s not very smooth:
Let’s add transitions to this dialog element and the backdrop. I’m going a bit faster this time because by now, you likely see the pattern and know what’s happening:
dialog {
opacity: 0;
translate: 0 30%;
transition-property: opacity, translate, display;
transition-duration: 0.8s;
transition-behavior: allow-discrete;
&[open] {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 -30%;
}
}
}
When a dialog is open, the browser slaps an open
attribute on it:
<dialog open> ... </dialog>
And that’s something else we can target with CSS, like dialog[open]
. So, in this case, we need to set a @starting-style
for when the dialog is in an open
state.
Let’s add a transition for our backdrop while we’re at it:
dialog {
/* etc. */
&::backdrop {
opacity: 0;
transition-property: opacity;
transition-duration: 1s;
}
&[open] {
/* etc. */
&::backdrop {
opacity: 0.8;
@starting-style {
opacity: 0;
}
}
}
}
Now you’re probably thinking: A-ha! But you should have added the display
property and the transition-behavior: allow-discrete
on the backdrop!
But no, that is not the case. Even if I would change my backdrop pseudo-element to the following CSS, the result would stay the same:
&::backdrop {
opacity: 0;
transition-property: opacity, display;
transition-duration: 1s;
transition-behavior: allow-discrete;
}
It turns out that we are working with a ::backdrop
and when working with a ::backdrop
, we’re implicitly also working with the CSS overlay
property, which specifies whether an element appearing in the top layer is currently rendered in the top layer.
And overlay
just so happens to be another discrete property that we need to include in the transition-property
declaration:
dialog {
/* etc. */
&::backdrop {
transition-property: opacity, display, overlay;
/* etc. */
}
Unfortunately, this is currently only supported in Chromium browsers, but it can be perfectly used as a progressive enhancement.
And, yes, we need to add it to the dialog
styles as well:
dialog {
transition-property: opacity, translate, display, overlay;
/* etc. */
&::backdrop {
transition-property: opacity, display, overlay;
/* etc. */
}
It’s pretty much the same thing for a popover instead of a dialog. I’m using the same technique, only working with popovers this time:
Other Discrete Properties
There are a few other discrete properties besides the ones we covered here. If you remember the second demo, where we transitioned some items from and to display: none
, the same can be achieved with the visibility
property instead. This can be handy for those cases where you want items to preserve space for the element’s box, even though it is invisible.
So, here’s the same example, only using visibility
instead of display
.
The CSS mix-blend-mode
property is another one that is considered discrete. To be completely honest, I can’t find a good use case for a demo. But I went ahead and created a somewhat trite example where two mix-blend-mode
s switch right in the middle of the transition instead of right away.
Wrapping Up
That’s an overview of how we can transition elements in and out of the top layer! In an ideal world, we could get away without needing a completely new property like transition-behavior
just to transition otherwise “un-transitionable” properties, but here we are, and I’m glad we have it.
But we also got to learn about @starting-style
and how it provides browsers with a set of styles that we can apply to the start of a transition for an element that’s in the top layer. Otherwise, the element has nothing to transition from at first render, and we’d have no way to transition them smoothly in and out of the top layer.