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:
<header>
<a href="/">LOGO</a>
<label>
<input type="checkbox">
</label>
<div class="menu-flyout">
<nav class="menu-main">
<a href="#">...</a>
<a href="#">...</a>
</nav>
<nav class="menu-cta">
<a href="#">CTA</a>
<a href="#">CTA</a>
</nav>
</div>
</header>
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, <header>
is a 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 <input type="checkbox">
. 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 <label>
with the toggler is hidden as expected, the <header>
is still a 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 <body>
-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 <label>
with:
<button
class="menu-toggle"
aria-label="Toggle flyout menu"
aria-expanded="false"
aria-controls="flyout-menu">
</button>
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
Top comments (11)
Great menu!
Is it possible to fix the effect of increasing the distance between items when you enlarge the window horizontally in mobile?
Yes! There are multiple gap's in use, though.
In
.menu-main
(the main items), the gap is usingclamp
, so it changes dynamically, depending on the viewport-width:gap: clamp(0.5rem, -3.875rem + 14vw, 4rem);
You can adjust these, or make your own here, or simply enter a static value in a "mobile-only" media-query.
The gap between the CTA-items is using the same method, but with smaller values.
Desktop:
On the wrapper, the space between the logo, the main items and the CTA is spaced with
justify-content: space-between
, but you can add agap
-property, if you want.Thank you!
This is a very clever and elegant solution to create a responsive menu in CSS. I love how you have used the :hover and :focus-within pseudo-classes to toggle the menu visibility. I also appreciate how you have explained the code and provided a demo link. This is a very useful tip for web developers. Thank you for sharing!
Thank you!
Great post! Really useful!
Thanks!
Great content!
Thank you!
This is super helpful, I’d like to try this out on my next project. Appreciate you sharing it.
Thank you!