Pure CSS Hamburger Menu

Jan 29, 2020

A how-to guide for how to create a hamburger menu with pure CSS

No JavaScript, no preprocessors, no icons or images. Just a sneaky little checkbox. This walkthrough is beginner friendly :)

As part of one of my Week 3 projects at Lambda School, we were tasked with creating our personal portfolio website. I wasn't happy with the menu I had on my site, so I decided to challenge myself and figure out how to make a hamburger menu without relying on any icons or JavaScript. Mostly because we hadn't learned JavaScript yet, but also because it would hopefully be a bit more performant without any event listeners.

Full Code

TL;DR view the full code on CodePen.

HTML

HTML
<header>
<h1><a href="#">Logo here</a></h1>
<input class="menu-btn" type="checkbox" id="menu-btn" name="menu-btn" />
<label class="menu-icon" for="menu-btn">
<span class="navicon" aria-label="Hamburger menu 'icon'" />
</label>
<nav class="menu">
<a class="nav-item" href="#">About Me</a>
<a class="nav-item" href="#">Blog</a>
<a class="nav-item" href="#">Contact</a>
</nav>
</header>

CSS

Some added styling for aesthetics, in addition to the functioning hamburger menu

CSS
body {
margin: 0;
}
header {
display: flex;
width: 100%;
}
header h1 {
margin-left: 1rem;
}
header a {
text-decoration: none;
}
/* "Hide" checkbox -- moves it off screen*/
#menu-btn {
position: absolute;
top: -100%;
left: -100%;
}
/* Hide hamburger for bigger screens */
.menu-icon {
visibility: hidden;
}
.menu {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 250px;
margin-right: 2rem;
}
/* Set width for mobile/smaller screen size. */
/* I set it big here so I don't have to shrink the screen so much */
/* for testing purposes */
@media screen and (max-width: 1100px) {
header {
display: grid;
grid-template-areas:
"title title hamburger"
"nav nav nav";
}
h1 {
grid-area: title;
}
.menu a {
text-decoration: none;
color: black;
}
.menu-btn {
display: none;
}
.menu-icon {
grid-area: hamburger;
cursor: pointer;
display: flex;
justify-content: flex-end;
align-items: baseline;
padding: 30px 20px 30px 0;
position: relative;
user-select: none;
visibility: visible;
}
.navicon {
background: #333;
display: block;
height: 2px;
width: 18px;
position: relative;
}
.navicon:before {
top: 5px;
}
.navicon:after {
top: -5px;
}
.navicon:before,
.navicon:after {
background: #333;
display: block;
width: 100%;
height: 100%;
content: "";
position: absolute;
transition: all 0.2s ease-out;
}
.menu {
grid-area: nav;
max-width: unset;
max-height: 0;
transition: max-height 0.2s ease-out;
overflow: hidden;
margin: 0;
padding: 0;
background-color: #fff;
display: flex;
flex-direction: column;
}
.menu a {
padding: 20px 20px;
border-right: 1px solid #f4f4f4;
background-color: #eee;
width: 100%;
text-align: center;
}
.menu-btn:checked ~ .menu {
max-height: 240px;
}
.menu-btn:checked ~ .menu-icon .navicon {
background: transparent;
}
.menu-btn:checked ~ .menu-icon .navicon:before {
transform: rotate(-45deg);
}
.menu-btn:checked ~ .menu-icon .navicon:after {
transform: rotate(45deg);
}
.menu-btn:checked ~ .menu-icon .navicon:before,
.menu-btn:checked ~ .menu-icon .navicon:after {
top: 0;
}
}

Structuring the HTML

If we're not using any JavaScript event listeners, how can we tell the menu to listen for a user input? We can implement an input tag, like a checkbox! When the user checks the box, we can use that to trigger the menu to drop down. However, we don't want the user to be able to actually SEE the checkbox, right? That'd be pretty tacky. Well, we can easily "hide" it by positioning it off screen. But then how can we check the box if it's not there anymore?

Enter the label tag. We can create a label tag associated with the input so that when the user clicks on the label, it will check the box the same as if the box itself had been clicked on. Pretty neat! Let's see how it this would look:

HTML
<input type="checkbox" id="menu-btn" name="menu-btn" /> <label for="menu-btn" />

Let's break this down a bit. In the input tag, the type attribute allows us to specify what kind of input it is, such as radio, checkbox, text, etc. The id is to let us connect it with a label. This part is very important. The for attribute on the label tag MUST match the id of the corresponding input. Once you have the label and input connected, clicking on the label will act just the same as clicking on the input checkbox!

Now that we have our input, we can trigger different CSS stylings depending on whether or not the input is checked! We're one step closer.

Let's take a step back at the bigger picture of what we want our header to look like (assuming you're putting your hamburger menu up in your header, of course). For larger viewports, I want my logo or h1 to be on the same "line", if you will, with my nav links. And then when the screen is smaller, I want the hamburger menu to be in the spot that the nav was just in. Then when we click on the hamburger to "open" the menu, I want the nav to drop down below them. How do we structure the HTML of the whole header to make all of that possible? This was one of the biggest struggles I had when trying to figure this out!

Here is the solution I came up with: at larger screen sizes, I can use display: flex for the header and then at sizes where I want the hamburger menu, I can create a media query and change the header styling to be display: grid.

Let's break this down. If my HTML is structured like so:

HTML
<header>
<h1><a href="index.html">Logo here</a></h1>
<input class="menu-btn" type="checkbox" id="menu-btn" name="menu-btn" />
<label class="menu-icon" for="menu-btn">
<!-- don't worry about this right now -->
<span class="navicon" aria-label="Hamburger menu 'icon'" />
</label>
<nav>
<a class="nav-item" href="aboutme.html">About Me</a>
<a class="nav-item" href="blog.html">Blog</a>
<a class="nav-item" href="contactme.html">Contact Me</a>
</nav>
</header>

h1, input, label, and nav are all siblings of header. So in the CSS when I make the header a flex container (via display: flex), all of those siblings are now flex items. This allows me to set flex-flow: row wrap for the header styling so that they are all on the same row together. Great!

Now that we have our HTML structured with a little bit of styling, now we can take care of hiding our checkbox. We can add the following to our CSS:

CSS
#menu-btn {
position: absolute;
top: -100%;
left: -100%;
}

Ok, so what exactly is going on here? #menu-btn is the id that we gave our input element. position: absolute lets us essentially move the input off the screen via the top and left properties. Setting position: absolute lets us set top: -100% and left: 100%, which will move our input (#menu-btn) 100% of the size of the containing block's height UP and 100% of the size of the containing block's width to the LEFT. This effectively moves our input checkbox off screen. Now no one will see it, but it can still be affected by user clicks on the label. Ta-da! Easy peasy, as Bob Newby would say.

I recommend checking out the MDN docs if you're not familiar with the position property. They include a nice playground so you can visually see the differences, too.

Now that we've successfully "hidden" our input (#menu-btn), now we need to make our label invisible. We don't need a clickable hamburger menu at bigger screen sizes. To do this, we can simply set visibility: hidden for our label. Then in our media query later when we want the hamburger menu to show, we can set it back to visibility: visible.

Alright, all we see in our header now is the logo/h1 and the nav! Yay! We know we can use a media query to change the styling when we have a different screen size, but what would we do? We didn't actually do much styling yet...And where's this hamburger menu? We don't have it yet! We don't need it until we reach our media query, right? (Assuming you started with a desktop-first approach and are using a max-width media query). Buckle up! This is where the fun begins :)

Set up the Header for the Hamburger

Let's revisit our HTML structure a bit. Our logo/h1, input, label, and nav in header are all siblings, right?

HTML
<header>
<h1><a href="index.html">Logo here</a></h1>
<input class="menu-btn" type="checkbox" id="menu-btn" name="menu-btn" />
<label class="menu-icon" for="menu-btn">
<!-- don't worry about this right now -->
<span class="navicon" aria-label="Hamburger menu 'icon'" />
</label>
<nav>
<a class="nav-item" href="aboutme.html">About Me</a>
<a class="nav-item" href="blog.html">Blog</a>
<a class="nav-item" href="contactme.html">Contact Me</a>
</nav>
</header>

We made header a flex container and set all of the flex items in a row:

CSS
header {
display: flex;
flex-flow: row wrap;
}

But wait. For a smaller screen, we just want the logo/h1 and the hamburger on the same row. We want the nav with all of the links to show up below those. Oh no! We structured our HTML wrong! Nope, this is where we can use the magic that is CSS grid. If you're not familiar with CSS grid, I highly recommend the tutorial by Wes Bos. He does a great job and it's free!

Assuming you know a bit about CSS grid, we're going to use that for our header inside of our media query for smaller screens. We want to override the display: flex and now style header to be display: grid. Why are we using grid? Well, excluding our input (#menu-btn) that we've positioned off screen, we have 3 items in our header: the logo/h1, label, and nav. But now we just want the logo/h1 and hamburger icon on one row and the nav on another row below those. This is where grid comes in. Set to display: grid, we can now give header some grid-template-areas to create 3 columns and 2 rows for our content, while simultaneously naming them:

CSS
header {
grid-template-areas:
"logo logo hamburger"
"nav nav nav";
}

Assuming you want your logo/h1 in the lefthand corner and the hamburger icon in the righthand corner, we can do something like this with our grid areas. Then all we have to do is call the selectors for the logo/h1, label, and nav and assign them their corresponding grid-area names. Then CSS grid works its magic and places them there!

CSS
h1 {
grid-area: logo;
}
.menu-icon {
grid-area: hamburger;
}
.menu {
grid-area: nav;
}

Hamburger Icon

Alrighty, let's talk about this hamburger icon. Finally. Something that we haven't talked about in the HTML snippets is the span element nested inside label. Giving label a child that spans the width of the label makes our "clickable input area", so to speak, larger so that there's a bigger area that we can click on to trigger our drop down menu.

HTML
<header>
<h1><a href="index.html">Logo here</a></h1>
<input class="menu-btn" type="checkbox" id="menu-btn" name="menu-btn" />
<label class="menu-icon" for="menu-btn">
<span class="navicon" aria-label="Hamburger menu 'icon'"></span>
</label>
<nav>
<a class="nav-item" href="aboutme.html">About Me</a>
<a class="nav-item" href="blog.html">Blog</a>
<a class="nav-item" href="contactme.html">Contact Me</a>
</nav>
</header>

Let's give span some styling so that it looks like a hamburger icon.

CSS
.navicon {
background-color: black;
display: block;
position: relative;
width: 18px;
height: 2px;
}
.navicon:before {
top: 5px;
}
.navicon:after {
bottom: 5px;
}

The background-color of the span (.navicon) is whatever color you want the hamburger "lines" to be. Then we want to set display: block and give it some height and width so we can actually see the color that we just set the background to.

But wait. Only one line shows up! Hamburger menus usually have at least 2, usually 3! This is where the :before and :after pseudo elements come in. These let us add some stylistic content to their parent element without having to add additional HTML. :before gets displayed "before" its parent and :after gets displayed "after".

However, if we don't give any positioning to these pseudo elements and since our span is so small, it looks like they just sit right on top of the original. We can easily fix that by giving some top and bottom positioning. They'll move relative to the span (.navicon) since it has relative positioning. Voila! A 3-line hamburger menu!

Side note: giving the span an aria-label is for accessibility purposes so that people using a screen reader will know why there's this random span that has no content.

Trigger Drop Down Menu

Since our burger is nested inside of our label, clicking the hamburger will provide the check for our input. So let's make it do something!

First, let's make sure that our nav is "hiding" so that it's not visible when the hamburger isn't clicked. We can set max-height: 0 and have overflow: hidden.

CSS
.menu {
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease-out;
}
.menu-btn:checked ~ .menu {
max-height: 250px;
}

Makes sense, right? If the height is zero, then we won't be able to see the nav. Hiding the overflow enusres that there's no scroll for the content that's too big for the provided height. :checked is a pseudo selector that gives styling to that element only if the checkbox input is checked. The ~ symbol is a general sibling selector . In this case, all elements with the class .menu that follow .menu-btn will receive this styling when .menu-btn is checked. So, when input (.menu-btn) is checked, the height of the nav (.menu) will changed from 0 to 250px. We can add a transition to the nav (.menu) to make it smoother. Now we have a working hamburger menu!

Animate Hamburger

If we want to be a little bit fancy and have the hamburger menu turn into an X when the nav drops down, we can give it some transition and transform properties. First, let's change the background:

CSS
.menu-btn:checked ~ .menu-icon .navicon {
background: transparent;
}

So what is this doing? When our input (.menu-btn) is checked, give the span (.navicon) a transparent background. Note that this only gives the original one a transparent background and not the :before and :after lines that were created from it. So this leaves us with the top and bottom lines, with the middle hamburger line "missing"!

Now to make them form an X! We currently have 2 parallel lines, so essentially all we need to them to do is rotate 45 degrees. We can do that with the transform property.

CSS
.menu-btn:checked ~ .meni-icon .navicon:before {
transform: rotate(-45deg);
}
.menu-btn:checked ~ .meni-icon .navicon:after {
transform: rotate(45deg);
}

Now that they're rotated, we can tweak the positioning a bit so that they intersect in the middle! We can simply override the top and bottom positioning that had before.

CSS
.menu-btn:checked ~ .menu-icon .navicon:before,
.menu-btn:checked ~ .menu-icon .navicon:after {
top: 0;
}

Even though the :after element originally had styling for the bottom, top styling trumps bottom styling. So now they are positioned properly to form an X! Now for the icing on the cake: add a transition to the span and it will look smooth as silk.

CSS
.navicon:before,
.navicon:after {
transition: all 0.3s ease-out;
}

Side note: we can give our label (.menu-icon) some padding so that the clickable area is a bit bigger, making it easier for the user to trigger the dropdown menu.

View the full HTML and CSS example on CodePen.

Dive Deeper

Here are some resources if there are concepts used here that you don't quite have a grasp on yet: