Mobile-to-Desktop Menu in 2 Lines of CSS
Tags: webdev, css, tutorial, javascript
Published: 2023-05-13 08:38:27


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:

```html
<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:

![Mobile Menu Collapsed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tejyrzfa062x8psal213.png)

`.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:

```css
.menu-flyout {
  inset: 0;
  position: fixed;
  translate: -100vw 0;
}
```

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

```css
flex-direction: var(--menu-flyout-dir, column);
```

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

![Flyout](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gq78l4h8tl2rhh5fy6h5.png)

---

The toggler is just a styled `<input type="checkbox">`. We can use this to reveal the flyout when clicked:

```css
header:has(input:checked) .menu-flyout {
  translate: 0;
}
```

![Flyout](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jcunvin3sc6z3v85aa9g.png)

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

```css
@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:

![Desktop](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7ii6fbddnpnr1wxcuwdw.png)

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:**

```css
@media (min-width: 768px) {
  .menu-flyout {
    --menu-flyout-dir: row;
    display: contents;
  }
}
```

Which results in:

![Desktop menu](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j278hjdn6ob6tbibs1tx.png)

**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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/viygnl4nu54laig6vbv1.png)

**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`:

```css
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:

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

![Large Desktop](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/83j2l8r8di7sniatw1hr.png)

---

## 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:

```css
@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:

```css
header {
  --menu-pb: .75em;
  --menu-pi: 1.5em;
}
```

... and later on, we'll define the padding:

```css
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

{% codepen https://codepen.io/stoumann/pen/YzJLyEB %}

---

## 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:

```html
<button
  class="menu-toggle"
  aria-label="Toggle flyout menu"
  aria-expanded="false"
  aria-controls="flyout-menu">
</button>
```

Then add a small JS-snippet:

```js
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`:

```css
.menu-open .menu-flyout { translate: 0; }
```

## Demo

{% codepen https://codepen.io/stoumann/pen/ZEqRVvr %}

---

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](https://www.pexels.com/photo/blue-and-white-mesh-net-3653997/)