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.

Two hands with intertwined index fingers. Each index finger has a black anchor tatoo. 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

Posted on