Table of Contents

Pure CSS toggles (or how to make mobile menus without JavaScript)

We usually turn to JavaScript for things like toggling menus or modals. But what if I told you there’s a simpler way to achieve this using only HTML and CSS? No JavaScript required. Let’s dive into building a basic menu toggle using just pure CSS.

This method takes advantage of how a <label> element can expand the clickable area of a nested <input type="checkbox">. Wrapping the checkbox inside the label gives us an easy, user-friendly interaction. If you want more on how labels work, check out this MDN article: developer.mozilla.org/en-US/docs/Web/HTML/Element/label.

HTML Setup

Here’s a basic structure for our toggle—a menu that will show or hide when a button is clicked:

<div class="menu--wrapper">
    <label>
        <input type="checkbox" />
        <div class="menu--btn">Click me</div>
    </label>
    <div class="menu">
        <ul>
            <li>Menu Item 1</li>
            <li>Menu Item 2</li>
        </ul>
    </div>
</div>

Key Points:

  • <label>: The label acts as the clickable area. With the checkbox inside, it allows us to toggle visibility without JS.
  • <input type="checkbox">: This checkbox controls the toggle state. Once checked, the menu will be displayed using CSS.
  • .menu--btn: This is the button users click to trigger the toggle. No JavaScript is needed.
  • .menu: This is the menu that gets toggled on and off based on the checkbox state.

The CSS

Now let’s bring it to life. We’ll hide the menu by default and display it when the checkbox is checked. Here’s the CSS:

.menu {
    display: none;
}

label:has(input:checked) ~ .menu {
    display: block;
}

That’s it. All that’s needed is to add your own visual styling.

What’s Going On:

  • Hide the menu by default: The .menu is hidden using display: none;.
  • Show on toggle: The key is the :has() pseudo-class, which works in modern browsers. This lets us target a parent element based on its child’s state. When the checkbox is checked, the adjacent .menu is displayed using the sibling selector (~).

Why No IDs?

You might be wondering, why not just use IDs? IDs can be useful for linking elements, but in this case, they’re not needed. I prefer to avoid them when possible because they get added to the global scope and can cause issues if not carefully managed. However, if we wanted to add a second button (a close one inside the menu), we might have to reach for IDs.

Wrapping It Up

That’s it—pure CSS for a fully functional toggle, no JavaScript needed, and no reliance on IDs. We’ve combined a hidden checkbox with the :has() pseudo-class to build something simple and effective.

You can apply this same technique for other toggle-based components, like dropdowns, modals, or even dark mode switches. So next time you’re looking for a lightweight solution, remember CSS can handle a lot on its own.

Practice: mobile menu

Let’s see how we can make a mobile menu using this technique.

The HTML is very simple, we have a wrapper element, our label which is used to toggle the menu, and the mobile menu itself.

<div class="wrapper">
    <label>
        <input type="checkbox" />
        <div class="button">=</div>
    </label>

    <div class="mobile-menu">
        <ul>
            <li>Page 1</li>
            <li>Page 2</li>
            <li>Page 3</li>
        </ul>
    </div>
</div>

The CSS is slightly more involved, though the majority is just basic visual styling and layout:

  /* our wrapper element should be a fixed size */
  .wrapper {
    display: flex;
    flex-direction: column;
    border: 1px solid #ccc;
    background-color: white;
    width: 250px;
    height: 150px;
  }

  /* label is the size of the button */
  label {
    width: 20px;
    height: 20px;
  }

  /* hide the checkbox */
  label input[type="checkbox"] {
    display: none;
  }

  /* our button should rotate when toggled, add an animation */
  .button {
    width: 100%;
    height: 100%;
    text-align: center;
    transition: trasform 2s;
    cursor: pointer;
    font-size: 1.2em;
  }

  input[type="checkbox"]:checked + .button {
    transform: rotate(90deg);
  }

  /* animate the mobile menu expanding when toggled */
  .mobile-menu {
    margin-top: 20px;
    overflow-x: hidden;
    transition:
      width 2s,
      border-width 2s;
    width: 0;
    height: 100%;
    border-style: solid;
    border-color: #cccccc;
    border-width: 0;
  }

  label:has(input[type="checkbox"]:checked) + .mobile-menu {
    width: 100%;
    border-width: 1px;
  }

  ul {
    padding: 0;
    padding-left: 15px;
    margin: 0;
  }

  li {
    text-wrap: nowrap;
    list-style-type: none;
    padding: 5px;
    display: block;
    width: 150px;
    cursor: pointer;
  }

See it in action:

  • Page 1
  • Page 2
  • Page 3