As a frontend developer, I’ve created numerous menus over the years. These menus have typically been the most intricate element of a website, incorporating mobile interactions, desktop transitions, item reordering, and more. In one instance, I built a mega-menu for a client that was so expansive, users mistook it for an entire webpage due to its full-page coverage.
However, in my most recent project, I aimed for simplicity by utilizing some of the latest and greatest CSS features available.
Let’s dive in!
Structure
For the markup, the simplest structure I could come up with, that still supported the flexibility needed to go from a mobile flyout to a desktop-menu, was this:
href="/">LOGO
type="checkbox">
class="menu-flyout">
We’ll add more classes and stuff later, but this is the basic structure. This version will work without any JavaScript, but has an accessibility-issue, we’ll look into later as well.
The main element,
flex
-container, using justify-content: space-between
to place the logo left and the toggler right:
.menu-flyout
is the container for two navigation-blocks: one with the main menu-items, and one with CTA’s (call-to-actions).
The flyout will cover the whole screen when visible, but otherwise be placed off-screen:
.menu-flyout {
inset: 0;
position: fixed;
translate: -100vw 0;
}
The navigation blocks are both flex
-containers with flex-direction
set to a custom property:
flex-direction: var(--menu-flyout-dir, column);
The second navigation block is placed at the bottom, using justify-content: end
:
The toggler is just a styled . We can use this to reveal the flyout when clicked:
header:has(input:checked) .menu-flyout {
translate: 0;
}
Since the toggler is wrapped in a label, we can use this to hide it on desktop:
@media (min-width: 768px) {
label { display: none; }
}
NOTE: Don’t worry, everything will have classes in the final examples, it’s just to simplify the examples, that I use plain tags!
So far, so good. When we resize the screen to desktop, we get:
OK, so the with the toggler is hidden as expected, the
flex
-container, the flyout is still placed off-screen, but is now gigantic, taking up the whole screen real-estate.
Let’s fix that with two lines of CSS:
@media (min-width: 768px) {
.menu-flyout {
--menu-flyout-dir: row;
display: contents;
}
}
Which results in:
Yay! If we inspect that, we’ll see that the two navgation blocks are now “direct” items in our main flex
-container, and .menu-flyout
seems to have disappeared:
So what’s going on?
display: contents
is the same as saying to an element: “Forget your own box, just display your child-nodes”. Or in formal MDN-lingo:
These elements don’t produce a specific box by themselves. They are replaced by their pseudo-box and their child boxes.
We change the flex-direction for both navigation-blocks, by updating the custom property, we declared earlier, --menu-flyout-dir
.
We now have a working, mobile-to-desktop menu using very little HTML and CSS and no JavaScript at all.
Let’s see what else we can do. I’ve set the desktop max-width to 1200px
, but would like to “stretch” the background-color
to the edge of the screen.
This used to require an extra element around the menu, but can now be done with a very large border-image
:
border-image: conic-gradient(
hsl(240, 10%, 20%) 0 0)
fill 0//100vw;
OK … this will actually cover the whole screen.
Let’s add a clip-path
to fix that:
clip-path: polygon(
-100vw 0,
100vw 0,
100vw 100%,
-100vw 100%
);
Now, even on very large desktops, the background will stretch to the edge of the screen (you probably need to zoom-in to see it!):
Mobile fixes
On mobile, we can use :has
to detect when the toggler is checked, even from the -element. We can utilize this to prevent overflow/scrolling, when the flyout is visible:
@media (max-width: 767px) {
body:has(.menu-toggle:checked) {
overflow: hidden;
}
}
If you rotate your (large) phone, the menu will switch to the desktop-version.
If you have an iPhone with a “notch”, the menu will “go into” that notch. We can fix this with the env()
-function and safe-area-inset
. First, we create two variables with block- and inline-padding:
header {
--menu-pb: .75em;
--menu-pi: 1.5em;
}
… and later on, we’ll define the padding:
header {
padding:
var(--menu-pb)
calc(env(safe-area-inset-right)
+ var(--menu-pi))
var(--menu-pb)
calc(env(safe-area-inset-left)
+ var(--menu-pi));
}
Now, when you rotate your phone, extra padding will be added inline, if the phone has a notch!
Demo
Accessibility Concerns
While the menu above works fine without any JavaScript, the toggle-button-checkbox is a hack, not working well with screen-readers.
Let’s add an id
to the flyout, and replace the with:
Then add a small JS-snippet:
const toggle = document.querySelector(
'.menu-toggle');
toggle.addEventListener('click', () => {
toggle.setAttribute(
'aria-expanded', document.body
.classList.toggle('menu-open')
);
})
This snippets toggles a class, menu-open
on the body-element. We’ll use the status, whether the class is set or not, for the aria-pressed
-attribute.
All the places we used :checked
before, we should now use menu-open
:
.menu-open .menu-flyout { translate: 0; }
Demo
The demos have a lot more stuff going on (transitions, clamped gaps, hover etc.) than I showcased in this article.
Open them on Codepen to see the full-width desktop-view — fork them — and play around with them.
Let me know what you think in the comments.
Photo by Brett Sayles