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
<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
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:
<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:
<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:
#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?
<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:
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:
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!
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.
<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.
.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
.
.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
:
.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.
.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.
.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.
.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: