Cover image for Creating a fold out navigation with CSS only

Creating a fold out navigation with CSS only

cydstumpel profile image Cyd Updated on ・4 min read

While using JavaScript to open a mobile navigation is a solid option, it's also possible to do it with CSS only. Even submenus don't necessarily need any JS to work, pseudo classes and sibling selectors are the answer!


Fold out navigations are usually triggered by what's been lovingly called the hamburger menu, referring to the three horizontal lines. This has been a much debated icon, but people have gotten used to it, so for now let's stick to it.

Opening and closing a fold out navigation

To create a CSS only navigation we need to use an <input /> element with type="checkbox", we will use the :checked pseudo class to see if it's selected. And we need a <label> that's linked to the input element with a for attribute.
This is the simplified version of what I usually create, for clarity's sake I've styled on elements rather than classes:

  <input type="checkbox" id="trigger" />
  <label aria-label="open main navigation" for="trigger"><span></span></label>
      <a href="#">Link</a>
      <a href="#">Link</a>
      <a href="#">Link</a>

Note that I put the label element after the input element, this makes it possible to style the label by the input's state (:focus, :checked, etc).

Hiding the checkbox

The checkbox input needs to be entirely gone. I added position fixed because the rest of the navigation is fixed and standard behaviour of clicking on a checkbox label is scrolling to it, which would mean scrolling to the top.

input {
  opacity: 0;
  width: 0;
  height: 0;
  appearance: none;
  position: fixed;

Creating the trigger

You can just add a background image of a hamburger icon here or you can use before and after selectors to create them. This is the visible part of the trigger, this will be your "nav is closed!"-state.

You might have wondered about the 'unnecessary' span inside the label tags, I added it to make it styling it easier, you can leave the span out.

label {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  width: 15px;
  height: 10px;
  margin: 40px;

label span {
  display: block;
  width: 100%;
  background: hotpink;
  height: 2px;
  transition: transform 1s;

label span::before,
label span::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  display: block;
  height: 2px;
  background: hotpink;
  transition: 0.4s

label span::before {
  top: 0;

label span::after {
  top: calc(100% - 2px); /* to make the transition easier :) */

Hiding the navigation

While you could just add display: none here, and display: block when you want to show it, using a transform is a lot more fun.
I chose to translate the ul 100vw on the X axis to hide it from the screen until the user clicks on the trigger.

ul {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  transform: translateX(100vw);
  transition: transform 0.2s;
  background: white;

Creating the 'open state'

I talked earlier about using the :checked pseudo class, a pseudo class is a keyword that's added to an element when it has a certain state.

Radio- and checkbox input types have a :checked state. Checkboxes can be checked and unchecked by clicking on them, in order to select another radiobutton there needs to be another button. Checkboxes are more suitable for our goals so we use them.

This is the place to add the css for the nav trigger's open state, a lot of websites don't style and animate this and it's a shame in my opinion.

The code may look intimidating if you're new to css but if you read it out loud it sometimes helps to understand it more clearly. The first line for example: select the inner element 'span' of the sibling 'label' of the checked 'input'.
Okay that still sounded a bit like gibberish, practice really does make perfect here.

input:checked + label span {
  transform: rotate(45deg);

input:checked + label span::before, input:checked + label span::after {
  top: calc(50% - 1px);
  transition: 0.4s;
input:checked + label span::after {
  transform: rotate(90deg);

Showing the navigation

I used the general sibling selector here, this means "select any ul that is a sibling of a checked input", input:checked + label + ul would also have worked, but is more sensitive to errors (what if you add an element in between?).

input:checked ~ ul {
  transform: none;
  transition: transform 0.4s; 

The pretty SCSS version with beziers and all the fancy stuff:

But why?

Because we can!

This may seem a lot more complicated than just adding a class with JavaScript and you may be right. This is not only a great exercise if you want to get better at handling pseudo classes and sibling selectors, it's also very sturdy. Imagine your website has a small error in the JS, this would mean people couldn't use your navigation anymore. Let's leave JavaScript for the crazy animations and keep simple things like opening and closing a navigation as a CSS thing.


A good point to throw away this entire article is that using an input element to open and close a navigation is not semantic.

But really if you think about it, most people using screen readers (the group that might notice that you used an input element in stead of a more semantic button element) aren't exactly developers, you might really annoy the blind developers though, be ware.

The trigger is automatically accessible with the keyboard, if you also add a clear aria-label you've done more than most developers who use a button to open a fold out navigation.

I would really love some input from actual blind users or accessibility experts, let me know in the comments whether I'm completely clueless.

Posted on Dec 29 '19 by:

cydstumpel profile



Freelance Web developer from Amsterdam


markdown guide
Sloan, the sloth mascot Comment marked as low quality/non-constructive by the community View code of conduct

yo stupid, she speaks dutch and english. stop this spanish bs in the comment sections of english websites.

Sloan, the sloth mascot Comment marked as low quality/non-constructive by the community View code of conduct

just doesnt make sense why theyre speaking spanish.

btw great article

Maybe he doesn't know english, there's no need to be rude.

Great article =)

Great article.
BTW this website isn't restricted about languages. Any one can speak their own language.

Actually it is, I've read it somewhere

DEV doesn't have any restrictions on languages, and we are working to actively support them.

@imcanada comments and replies should be constructive, respectful, and show a level of empathy toward others. Please take some time and review our Code of Conduct.

Sloan, the sloth mascot Comment marked as low quality/non-constructive by the community View code of conduct

ok æðislegt það er fullkomlega eðlilegt að ég sé að svara ummælum á ensku á íslensku.

doesnt help anyone though does it?

Take the feedback, Canada.


Hey Cyd,

First off, very nice work. I really like the CSS only approach you took. My only concern was as you pointed out at the end that your example is not semantic. As a result, using the wrong element can trigger the wrong browse mode in assistive tech which can be a bit confusing. But in this case, using the checkbox as a toggle might be ok. I need to do some more digging.

Aside from that, I tested your example using NVDA with Firefox and Chrome. Things worked great when I wasn't using my screen reader. However, I found that when using my screen reader and the hamburger menu was in focus, the checkbox was not clickable (Chrome). I also noticed that when in focus, NVDA didn't announce the purpose of the element(chrome and Firefox). So I took a look at the code to see what was going on and I found a few things that fixed the issues in both Chrome and Firefox.

Removing the height, width and appearance from .nav__trigger-input allowed for the checkbox to be clicked via the keyboard when the hamburger menu was in focus(Chrome). And by placing the aria-label on the input element, when in focus, NVDA announced, "open the navigation. not checked" (Chrome). Placing an aria-labeledby="trigger" and changing the for="trigger" on the label to id="trigger" allowed for the aria-label to be correctly announced by NVDA in both Chrome and Firefox.

_submenu-trigger-input {
opacity: 0;
position: fixed;

< input
aria-label="click to open the navigation"
< label class="nav
_trigger-finger" id="trigger">

I hope my suggestion helps.

Again, great work!!


Thank you!!! This is exactly what I was looking for. We might have to add border: none and background: none because for some weird reason checkboxes are really hard to make invisible in all browsers. I’m going to add this code en reference you in a bit!


No prob :)

I made an update. I noticed the explicit label while associated with the labeled by was not triggering the mouse click. So I tied the label to the input using hamburger as the id and for. Also added cursor: pointer; to .nav__trigger-finger.

< nav class="nav">
< input
aria-label="click to open the navigation"
< label class="nav
_trigger-finger" id="trigger" for="hamburger">
< span>


Hmmm I can actually use the keyboard to open the navigation in chrome, using tab and space to open. You also shouldn't use the same ID twice as they should be unique so Im going to call it something different. Still thanks for your help!


Yeah, I noticed that too! facepalm

I made a codepen with the updates


Hahaha looks great, I think the problem is that not the entire hamburger is clickable, I usually just use a svg to animate between states, but that seemed a bit too complicated for this article.

Great work!


Just a few findings from me:

1) The animation on a 27 inch display is a little bit to much. I personally find it not very pleasant after the second time
2) On mobile I can see the "C" from "contact"
3) On Firefox I have 2 scroll bars.
4) The Hamburger menu does not animate from 1 line to 3 lines. It just jumps to 3 lines.

In general I see this screen filing menus more and more. This to me only makes sense on Mobile where the space is limited but on a laptop or even on a 27 inch monitor it is for me not a very good user experience.

Besides that good article.


Hahaha you think I would actually use this in production? This is just to showcase what you can do with pseudo classes, don't take things too seriously.


I'm not taking it too serious :)

Maybe some (junior) devs will think that this should be used in production.

Like people use the codedrops examples in production and then the complete UI is super laggy ;)


Great article! Thank you! May I make a suggestion for future articles? Can you please show the browser window after you show the code snippets? It makes following along easier, and also makes it easier to know if there's an error. I went through your article and have something wrong. Unfortunately I don't know where in the code I made the error. Still a great article! keep it up


Oops, there was a mistake in the code; the li tags weren't closed, that might have thrown you off, here is the simple version of the navigation as shown in the article:



This is dope...Cyd
Love it ❤🤗


I understand the goal of this, but Wich value I need to modify to use only 50%,30%, etc, of screen after translate the ul? Thank you


I get it, the "left" attribute of ul. Thank you, is an interesting escenario


That’s a great option, you could also not set the left property and give it a width of 30vw (I would add a min width in pixels as well to keep it all readable) for example 🙂


Very cool, thanks!

Just something I noticed. I played with this on a basic one page site with anchors. When clicking a link, the menu stays open. So you need to close it to view the content.


Oh damn, you found the loophole, this might actually take some JavaScript


Would you be able to link a button element to the input?
I may play around with the idea later but to be semantic seems like we could wrap the input in a button parent element


Unfortunately no, the for attribute only works on the label element and that’s how we link the input and label. Also if you wrap the input in an element you can no longer use sibling selectors to show and hide the navigation 😔.


My big take away here is using a custom checkbox input to maintain the menu state. Perfect. Oh, and your fancy menu is amazing 😁


Great article! I saved it to my reading list for later review!!