JavaScript-less Mobile Menu with Tailwind
I've recently been working on a simple static website. One of the goals of the project was to challenge myself to improve my knowledge of CSS and Tailwind. I wanted to see how much I can do without a line of JavaScript. I explored different JavaScript-less options for mobile navigation menus, and the checkbox hack caught my attention.
It's simple, and it can teach you a thing or two about how to think outside of the box when it comes to your stylesheets. It can be made accessible with careful attention to ARIA attributes and focus management, though some advanced accessibility features (which are typically JavaScript-reliant) have limitations as discussed later. And here's a fun fact: you can use this pattern to toggle the visibility of anything on your page!
So here we are. Here's a JavaScript-less expandable / collapsible mobile navigation menu implemented with Tailwind. For those of you who may not be familiar with Tailwind CSS, it is a utility-first CSS framework that allows developers to rapidly build custom user interfaces directly in their markup by composing pre-defined classes. Instead of writing custom CSS for every element, you apply small, single-purpose utility classes like flex
, pt-4
, or text-center
directly to your HTML. This approach promotes consistency, reduces the need for context switching between HTML and CSS files, and results in highly optimized stylesheets as only the used utilities are bundled.
Keep in mind the code here is minimal – no fancy styling, no accessibility – you'll likely want to handle those aspects yourself anyway. This is just to show you how to toggle visibility of an element.
If you're here just for the snippet, I'll save you some time — here's the basic code:
<nav>
<div id="mobile-menu-container" class="md:hidden relative">
<input
id="mobile-menu-toggle"
type="checkbox"
class="peer absolute z-10 opacity-0"
/>
<span id="kebab"
class="relative z-0 block peer-checked:hidden"
>⁝</span>
<span
id="x-mark"
class="relative z-0 hidden peer-checked:block"
>✕</span>
<ul
id="mobile-menu"
class="hidden peer-checked:block"
>
<li><a href="ikea.com">Home</a></li>
<li><a href="buymeacoffee.com/vitcosoft">Charity</a></li>
<li><a href="medium.com/@vitcosoft">Reading</a></li>
</ul>
</div>
</nav>
But if you're curious, keep on reading for step-by-step instructions with some extras along the way. The pretty version of the menu is revealed along the way!
The Checkbox Hack Overview
The whole trick is based on three simple steps:
- Place an invisible checkbox above your menu icon;
- Place the menu outside the view;
- Toggle menu visibility using the subsequent-sibling combinator based on the checkbox's state.
And that is it! Add some criminally smooth animations, AI-powered icons, and you've got yourself a fully functional, JavaScript-less mobile menu. Congratulations! Easier than centering a div, eh?
Implementation with Tailwind
Let me walk you through the implementation steps, so you can fully grasp the process.
Step 1: Markup
Let's set up some basic HTML, so we can start applying the Tailwind magic. We'll add a <div>
to act as the mobile-menu-container
:
<nav>
<div id="mobile-menu-container">
</div>
</nav>
Next, we need to pick the façade (usually a hamburger or kebab icon) that will fool the user by pretending to toggle the menu. For simplicity, let's use some Unicode chars depicting three dots and an x-mark. We also need to introduce the main protagonist of our little trick — the checkbox:
<nav>
<div id="mobile-menu-container">
<input id="mobile-menu-toggle" type="checkbox"/>
<span id="kebab">⁝</span>
<span id="x-mark">✕</span>
</div>
</nav>
Finally, add the unordered list that will act as our navigation menu:
<nav>
<div id="mobile-menu-container">
<input id="mobile-menu-toggle" type="checkbox"/>
<span id="kebab">⁝</span>
<span id="x-mark">✕</span>
<ul id="mobile-menu">
<li><a href="ikea.com">Home</a></li>
<li><a href="buymeacoffee.com/vitcosoft">Charity</a></li>
<li><a href="medium.com/@vitcosoft">Reading</a></li>
</ul>
</div>
</nav>
The key consideration here is that the checkbox, the icons, and the list are all sibling elements, marked as peers in Tailwind.
Step 2: Tailwind styles
Now that we have our markup in place, we can start the styling.
Let's begin by making our container hidden
on larger screens and setting its position to relative
:
<nav>
<div id="mobile-menu-container" class="md:hidden relative">
<input id="mobile-menu-toggle" type="checkbox"/>
<span id="kebab">⁝</span>
<span id="x-mark">✕</span>
<ul id="mobile-menu">
<li><a href="ikea.com">Home</a></li>
<li><a href="buymeacoffee.com/vitcosoft">Charity</a></li>
<li><a href="medium.com/@vitcosoft">Reading</a></li>
</ul>
</div>
</nav>
Why do we want it relative
? Since this trick is dependent on the z-index
property, we want to keep things clean and create a "local" stacking context within our container. Also, z-index
only works with elements positioned otherwise than static
. Let's now add utility classes to the checkbox:
<nav>
<div id="mobile-menu-container" class="md:hidden relative">
<input
id="mobile-menu-toggle"
type="checkbox"
class="peer absolute z-10 opacity-0"
/>
<span id="kebab">⁝</span>
<span id="x-mark">✕</span>
<ul id="mobile-menu">
<li><a href="ikea.com">Home</a></li>
<li><a href="buymeacoffee.com/vitcosoft">Charity</a></li>
<li><a href="medium.com/@vitcosoft">Reading</a></li>
</ul>
</div>
</nav>
Here's a breakdown of what happened:
-
opacity-0
will make the checkbox invisible to eyes, while keeping its position and interactivity intact; -
z-10
places the checkbox one level above siblings withinmobile-menu-container
; -
absolute
places the input element outside the normal flow of the document, so it no longer occupies space where it would normally be placed, which allows us to place it above its siblings; -
peer
is a utility class that allows us to style sibling elements based on the properties and state of this particular element.
This is the minimal setup for our checkbox. Let's now shift our attention to the icons. The reason we have two icons is simple: they will switch places depending on the state of the checkbox:
<nav>
<div id="mobile-menu-container" class="md:hidden relative">
<input
id="mobile-menu-toggle"
type="checkbox"
class="peer absolute z-10 opacity-0"
/>
<span id="kebab"
class="relative z-0 block peer-checked:hidden"
>⁝</span>
<span
id="x-mark"
class="relative z-0 hidden peer-checked:block"
>✕</span>
<ul id="mobile-menu">
<li><a href="ikea.com">Home</a></li>
<li><a href="buymeacoffee.com/vitcosoft">Charity</a></li>
<li><a href="medium.com/@vitcosoft">Reading</a></li>
</ul>
</div>
</nav>
Let's break this down a bit:
-
relative
, again, allows elements to usez-index
, but does not move them from their normal document flow; -
z-0
— technically, we do not need to setz-index
here (the input will have already been placed above the icons), but we want to be explicit about our intent, which is placing these particular elements below the checkbox; -
block
andhidden
set initial states of the icons — the kebab as visible, and x-mark as initially hidden; -
peer-checked:hidden
andpeer-checked:block
hides thekebab
and reveals thex-mark
when thepeer
checkbox has state ofchecked
.
Strictly speaking, the trick is complete. We manipulate visibility of these elements based on the state of the checkbox. But the title promised you a mobile menu, so let's apply the same logic to the unordered list:
<nav>
<div id="mobile-menu-container" class="md:hidden relative">
<input
id="mobile-menu-toggle"
type="checkbox"
class="peer absolute z-10 opacity-0"
/>
<span id="kebab"
class="relative z-0 block peer-checked:hidden"
>⁝</span>
<span
id="x-mark"
class="relative z-0 hidden peer-checked:block"
>✕</span>
<ul
id="mobile-menu"
class="hidden peer-checked:block"
>
<li><a href="ikea.com">Home</a></li>
<li><a href="buymeacoffee.com/vitcosoft">Charity</a></li>
<li><a href="medium.com/@vitcosoft">Reading</a></li>
</ul>
</div>
</nav>
And that is basically it! We can now toggle the menu visibility without any JavaScript! This one's for all of the JavaScript haters out there.
(bonus) Step 3: Let's make it pretty!
If you made it till the end — below lies the prettier version of this menu. And more accessible, because accessibility is beautiful by definition. For instance, this enhanced version (seen in the linked example and the snippet you're reviewing) bolsters accessibility through several specific improvements:
-
Clearer Control Description: The underlying checkbox is often made visually hidden (e.g., using an
sr-only
class) but remains accessible to assistive technologies. It's given a descriptivearia-label
like"Open or close navigation menu"
to clearly state its purpose. -
Programmatic Linking: The
aria-controls
attribute is added to the checkbox to create a programmatic link with the menu panel (e.g.,id="mobile-menu-panel"
) that it shows and hides. This helps assistive technologies understand the relationship between the control and the content it affects. -
Focusable Visual Toggle: The visible part that users click or tap (often a
<label>
associated with the checkbox) is made explicitly keyboard focusable (e.g., usingtabindex="0"
if it's not an inherently focusable element acting as the label) and receives clear visual focus indicators (likefocus:ring-2 focus:ring-white
). -
Semantic Menu Region: The menu panel itself is given a
role="region"
and anaria-label
(e.g.,"Mobile navigation"
) to define it as a distinct landmark region, making it easier for screen reader users to navigate and understand the page structure. -
Enhanced Focus Visibility: All interactive elements within the open menu, such as navigation links and any explicit "Close menu" buttons (which might also be a
<label>
for the same checkbox), are designed with clear and visible focus styles.
Hopefully, these examples will inspire you to make something even better!
Check the code out here ==> play.tailwindcss.com
Note: In programming, every decision is a tradeoff
One feature of computer programming that makes us feel powerful and powerless all at the same time is the fact that everything can be done in a myriad of ways — which makes every decision a tradeoff. Here are some notable considerations regarding the advantages and drawbacks of this implementation of a mobile navigation menu:
Strengths:
- JavaScript-Free Operation: Works reliably without client-side scripting, enhancing robustness and broad compatibility.
- CSS-Powered Interactivity: Leverages performant, native CSS for smooth animations and state management, avoiding JavaScript overhead.
- Core Accessibility Can Be Achieved: The checkbox itself provides basic keyboard navigation. With additions such as ARIA attributes and custom focus indicators (as demonstrated in the linked example), good core accessibility is possible.
- Lightweight Implementation: Avoids JavaScript file weight and execution, potentially leading to faster component rendering.
Weaknesses:
-
Advanced Accessibility Gaps: Lacks JavaScript-dependent features like true focus trapping within the open menu or dynamic
aria-expanded
on a semantic button. - Semantic Compromise: Employs a checkbox for UI toggling, which is a functional workaround rather than the element's primary semantic role.
- Limited UX Patterns: Cannot implement common user experience enhancements like "click outside to close" or "Escape key" closure.
- Structural Rigidity: Requires a specific HTML element order and sibling relationships for the CSS selectors to function, reducing layout flexibility.
It's a cool hack, but a hack nonetheless. Thanks for reading!
Further reading
MDN Web Docs:
Subsequent-sibling combinator
Understanding z-index
ARIA guides
Tailwind CSS Docs:
Styling based on sibling state
Utilities for controlling the stack order of an element.
CSS-Tricks:
Three CSS Alternatives to JavaScript Navigation
Responsive Menu Concepts
Top comments (2)
Nice trick! Thanks for sharing.
Great approach to keeping mobile menus lightweight and definitely sharing this with my team.