Flexible Menu Animation with Anchored Container Queries
Chrome has just released a new version 143 with support for CSS anchored fallback container queries. These queries enable us to style an anchored element's descendants based on its current position relative to the anchor. That's pretty cool! 🤩
A few months ago, I published an article on building an accessible custom menu with the Popover API and CSS Anchor Positioning. In the article, I complained that I had to resort to JavaScript to make the scaling animation work for different positions of the menu panel relative to the menu button.
Well, not anymore! I'll show you how to define the menu panel's animation with CSS only.
Photo: © Snapwire / pexels.com
The Basics of Anchored Container Queries
The CSS Anchor Positioning Module Level 2 document introduces the
new anchored value for the container-type property. It states:
Establishes a query container for container queries, allowing for descendants of an anchor positioned element to be styled based on certain features of the anchoring. (Currently, limited to which of the position-try-fallbacks are applied, if any.)
In our use case, we have a custom menu panel that is anchored to its menu button. When we
apply container-type: anchored to the menu panel, we turn it into a query container that's aware of its
anchor positioning state.
This allows us to adapt the menu panel's scaling effect to its current position, using the new
at-rule @container anchored(fallback: ...). For example: When the panel is placed at the top and left
of the button, we let it grow out of the bottom right corner.
Before we take an in-depth look at the HTML and CSS code, check out the final demo of the custom menu.
Demo: Accessible Menu with CSS only animation
I've created a new CodePen demo that includes the same menu element four times, placed roughly at the four corners of the screen. This way, you can easily test how the animation of the menu panel takes into account the position relative to the menu button:
If your browser doesn't support anchored container queries, the menu panel will simply fade in. I'll show you the details of implementing the scaling effect as a progressive enhancement.
How to use Anchored Container Queries for Flexible Animations
I'm not going to repeat all the details of the accessible custom menu I built. Make sure to read my article “Let's build an Accessible Menu with Modern Web Features” first before you continue.
Step 1: The basic HTML structure
To make the anchored container query work, we need to slightly adapt the HTML structure of our custom menu. I tried to
apply the change to the div[role="menu"] element itself, but it had no effect. Why is that?
The official specification states that the container query only allows for descendants of an anchor positioned element to be styled. This means, we need an additional inner container:
<button id="menu-btn-1" class="menu-btn" aria-label="More options" popovertarget="menu-content-1" type="button">
<span>/* icon */</span>
</button>
<div id="menu-content-1" role="menu" aria-labelledby="menu-btn-1" popover>
<div class="menu-inner-box">
<button role="menuitem" type="button">...</button>
<button role="menuitem" type="button">...</button>
<button role="menuitem" type="button">...</button>
</div>
</div>
The .menu-inner-box element is a descendant of our menu panel. Therefore, we can change
its transform-origin depending on the menu panel's anchor positioning state.
Step 2: Menu Panel Animation
The panel should fade in and grow in size when it is opened. As the transition applies to a popover that changes
from display: none to display: flex, we also need to transition
the overlay and display properties. Here's the basic setup for the panel:
div[role="menu"] {
/* General animation and end of fade-out */
opacity: 0;
transition-property: opacity, overlay, display;
transition-behavior: allow-discrete;
transition-duration: 100ms;
transition-timing-function: linear;
}
The scaling animation is defined separately for the inner container. As the menu panel's default position is to the bottom
and right of the menu button, we set transform-origin: top left for the scaling effect:
div[role="menu"] > .menu-inner-box {
transform: scale(1);
transform-origin: top left;
transition-property: transform;
transition-duration: inherit;
transition-timing-function: inherit;
}
We want the fade-in to last 120 milliseconds and use a smooth cubic-bezier timing function. At the end of the transition, the popover is fully visible and scaled to 100 percent:
div[role="menu"]:popover-open {
/* End of fade-in and start of fade-out */
transition-duration: 120ms;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
opacity: 1;
}
div[role="menu"]:popover-open > .menu-inner-box {
transform: scale(1);
}
As the menu panel switches from a hidden to a visible state, there are no computed values that the transition can use as
a starting point. Therefore, we use @starting-style to set the starting opacity to 0 and the initial
scale to 80 percent:
/* Start of fade-in */
@starting-style {
div[role="menu"]:popover-open {
opacity: 0;
}
div[role="menu"]:popover-open > .menu-inner-box {
transform: scale(0.8);
}
}
With this setup, the scaling animation works for the default placement of the menu panel. Next, we'll use anchored container queries to adapt the animation to different positions.
Step 3: Position Fallbacks
Our menu button is implicitly defined as the anchor element of the menu panel. Therefore, we simply define the default placement of the menu panel and several fallbacks:
div[role="menu"] {
position: fixed;
position-area: end span-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
When the anchored element (the menu panel) overflows its containing block, the browser will automatically try the list of
alternative positions defined by the position-try-fallbacks property. It will use the first alternative
that doesn't overflow the containing block or viewport.
How can we detect which fallback position was picked by the browser? First, we turn the menu panel into a query container for its anchor position:
div[role="menu"] {
container-type: anchored;
}
Now we can define anchored queries to check the fallback and adapt the animation accordingly. Here's an example:
@container anchored(fallback: flip-block) {
div[role="menu"] > .menu-inner-box {
transform-origin: bottom left;
}
}
When the menu panel's position is flipped in the block axis (moved to the top), then this at-rule will take effect and make the panel appear to grow outwards from the menu button. Awesome! 😍
You can find the rest of the necessary container queries in the CSS code of my CodePen demo.
Step 4: Progressive Enhancement
Last but not least, we don't want the animation of the menu panel to break on browsers that don't support anchored container
queries yet. Using the @supports at-rule, we only define the scaling effect for the inner container
as well as the container queries if the browser supports it:
@supports (container-type: anchored) {
div[role="menu"] {
container-type: anchored;
}
/* Styles related to the scaling effect animation */
}
Progressive enhancement has never been this easy!
Useful Resources
- Using CSS anchor positioning (MDN)
- position-try-fallbacks (MDN)
- Detect fallback positions with anchored container queries from Chrome 143
Posted on