DEV Community

Cover image for Mobile-to-Desktop Menu in 2 Lines of CSS
Mads Stoumann
Mads Stoumann

Posted on

Mobile-to-Desktop Menu in 2 Lines of CSS

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>
Enter fullscreen mode Exit fullscreen mode

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:

Mobile Menu Collapsed

.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;
}
Enter fullscreen mode Exit fullscreen mode

The navigation blocks are both flex-containers with flex-direction set to a custom property:

flex-direction: var(--menu-flyout-dir, column);
Enter fullscreen mode Exit fullscreen mode

The second navigation block is placed at the bottom, using justify-content: end:

Flyout


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;
}
Enter fullscreen mode Exit fullscreen mode

Flyout

Since the toggler is wrapped in a label, we can use this to hide it on desktop:

@media (min-width: 768px) {
  label { display: none; }
}
Enter fullscreen mode Exit fullscreen mode

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:

Desktop

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Which results in:

Desktop menu

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:

Flex container

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;
Enter fullscreen mode Exit fullscreen mode

OK ... this will actually cover the whole screen.

Let's add a clip-pathto fix that:

clip-path: polygon(
  -100vw 0,
  100vw 0,
  100vw 100%,
  -100vw 100%
);
Enter fullscreen mode Exit fullscreen mode

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!):

Large Desktop


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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

... 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));
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')
    );
})
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
alexpgmr profile image
Alex • Edited

Great menu!
Is it possible to fix the effect of increasing the distance between items when you enlarge the window horizontally in mobile?

Image description

Collapse
 
madsstoumann profile image
Mads Stoumann • Edited

Yes! There are multiple gap's in use, though.

In .menu-main (the main items), the gap is using clamp, 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 a gap-property, if you want.

Collapse
 
alexpgmr profile image
Alex

Thank you!

Collapse
 
andrew89 profile image
Andrew

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!

Collapse
 
madsstoumann profile image
Mads Stoumann

Thank you!

Collapse
 
mxglt profile image
Maxime Guilbert

Great post! Really useful!

Collapse
 
madsstoumann profile image
Mads Stoumann

Thanks!

Collapse
 
miracool profile image
Makanju Oluwafemi

Great content!

Collapse
 
madsstoumann profile image
Mads Stoumann

Thank you!

Collapse
 
scottwright_dev profile image
Scott

This is super helpful, I’d like to try this out on my next project. Appreciate you sharing it.

Collapse
 
madsstoumann profile image
Mads Stoumann

Thank you!