Mobile-First Navigation — Complete Guide
In this tutorial, you will learn about Mobile. We cover key concepts, practical examples, and best practices to help you master this topic.
Mobile-first navigation collapses into hamburger menus, priority+ patterns, or bottom navigation bars with touch targets, aria labels, and focus management.
What You'll Learn
- Hamburger menu pattern
- Priority+ navigation
- Bottom navigation bar
- Off-canvas vs overlay navigation
- Focus management for mobile nav
- Touch targets and spacing
- Navigation Accessibility
Why It Matters
- Navigation is the most common mobile pattern
- Poor mobile navigation causes user abandonment
- Each pattern suits different content structures
- Accessibility requirements are specific to mobile
Real-World Use
- A content site uses hamburger menu for many links
- A news site uses priority+ to show popular links
- An app uses bottom tab navigation for primary actions
- An e-commerce site uses a sticky bottom nav
flowchart LR A[Mobile Navigation] --> B[Hamburger Menu] A --> C[Priority+] A --> D[Bottom Nav] B --> E[Off-screen menu] C --> F[Show most important] D --> G[Sticky bottom bar]
Navigation Patterns
Code Example: Accessible Hamburger Menu
<header class="site-header">
<a href="/" class="logo">Site Name</a>
<button
class="nav-toggle"
id="nav-toggle"
aria-expanded="false"
aria-controls="nav-menu"
aria-label="Open navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<nav
id="nav-menu"
class="nav-menu"
aria-label="Main navigation"
hidden
>
<ul role="list">
<li role="listitem"><a href="/">Home</a></li>
<li role="listitem"><a href="/about">About</a></li>
<li role="listitem"><a href="/services">Services</a></li>
<li role="listitem"><a href="/blog">Blog</a></li>
<li role="listitem"><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<div class="nav-overlay" id="nav-overlay"></div>
<style>
:root {
--nav-width: 280px;
--transition-speed: 0.3s;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #fff;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.nav-toggle {
min-width: 44px;
min-height: 44px;
background: none;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
padding: 8px;
}
.hamburger-line {
display: block;
width: 24px;
height: 2px;
background: #333;
transition: transform var(--transition-speed) ease, opacity var(--transition-speed) ease;
}
.nav-toggle[aria-expanded="true"] .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.nav-toggle[aria-expanded="true"] .hamburger-line:nth-child(2) {
opacity: 0;
}
.nav-toggle[aria-expanded="true"] .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
.nav-menu {
position: fixed;
top: 0;
left: calc(-1 * var(--nav-width));
width: var(--nav-width);
height: 100%;
background: #fff;
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
transition: left var(--transition-speed) ease;
z-index: 200;
padding: 4rem 1rem 1rem;
overflow-y: auto;
}
.nav-menu:not([hidden]) {
left: 0;
}
.nav-menu a {
display: flex;
align-items: center;
min-height: 44px;
padding: 0.75rem 1rem;
color: #333;
text-decoration: none;
font-size: 1rem;
}
.nav-menu a:focus-visible {
outline: 3px solid #0066CC;
outline-offset: -3px;
}
.nav-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-speed) ease;
z-index: 150;
}
.nav-overlay.visible {
opacity: 1;
pointer-events: auto;
}
@media (min-width: 768px) {
.nav-toggle {
display: none;
}
.nav-menu {
position: static;
width: auto;
box-shadow: none;
padding: 0;
overflow: visible;
}
.nav-menu:not([hidden]) {
left: auto;
}
.nav-menu ul {
display: flex;
gap: 0.5rem;
}
.nav-menu a {
padding: 0.5rem 1rem;
min-height: auto;
}
.nav-overlay {
display: none;
}
}
</style>
<script>
const toggle = document.getElementById('nav-toggle');
const menu = document.getElementById('nav-menu');
const overlay = document.getElementById('nav-overlay');
function openNav() {
menu.hidden = false;
overlay.classList.add('visible');
toggle.setAttribute('aria-expanded', 'true');
toggle.setAttribute('aria-label', 'Close navigation menu');
document.body.style.overflow = 'hidden';
menu.querySelector('a').focus();
}
function closeNav() {
menu.hidden = true;
overlay.classList.remove('visible');
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', 'Open navigation menu');
document.body.style.overflow = '';
toggle.focus();
}
toggle.addEventListener('click', () => {
menu.hidden ? openNav() : closeNav();
});
overlay.addEventListener('click', closeNav);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !menu.hidden) closeNav();
});
</script>
Expected output: On mobile, a hamburger button shows. Clicking it slides the navigation from the left with an overlay. Focus moves to the first link. Escape closes the menu and returns focus. On desktop, the navigation is always visible horizontally.
Code Example: Priority+ Navigation
<nav class="priority-nav" aria-label="Main navigation">
<ul class="priority-list" id="priority-list">
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/services">Services</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/contact">Contact</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/support">Support</a></li>
</ul>
<button class="more-toggle" id="more-toggle" aria-expanded="false" aria-label="More links" hidden>
<span>More</span>
<span class="more-count" id="more-count"></span>
</button>
<div class="more-menu" id="more-menu" hidden>
<ul id="more-list"></ul>
</div>
</nav>
<style>
.priority-nav {
display: flex;
align-items: center;
position: relative;
}
.priority-list {
display: flex;
list-style: none;
margin: 0;
padding: 0;
overflow: hidden;
flex: 1;
}
.priority-list li {
flex-shrink: 0;
}
.priority-list a {
display: flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 1rem;
white-space: nowrap;
}
.more-toggle {
min-width: 44px;
min-height: 44px;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
}
.more-menu {
position: absolute;
top: 100%;
right: 0;
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-radius: 4px;
z-index: 100;
min-width: 200px;
}
.more-menu a {
display: flex;
align-items: center;
min-height: 44px;
padding: 0.75rem 1rem;
}
@media (min-width: 768px) {
.more-toggle {
display: none !important;
}
}
</style>
<script>
// Priority+ logic: hide items that overflow, show them under "More"
const list = document.getElementById('priority-list');
const moreToggle = document.getElementById('more-toggle');
const moreMenu = document.getElementById('more-menu');
const moreList = document.getElementById('more-list');
function updatePriority() {
const items = list.querySelectorAll('li');
const containerWidth = list.offsetWidth;
let totalWidth = 0;
let overflow = [];
items.forEach((item, i) => {
totalWidth += item.offsetWidth;
if (totalWidth > containerWidth - 60) {
overflow.push(item);
item.hidden = true;
} else {
item.hidden = false;
}
});
if (overflow.length > 0) {
moreToggle.hidden = false;
moreList.innerHTML = '';
overflow.forEach(item => {
const clone = item.cloneNode(true);
clone.hidden = false;
moreList.appendChild(clone);
});
document.getElementById('more-count').textContent = overflow.length;
} else {
moreToggle.hidden = true;
}
}
window.addEventListener('resize', updatePriority);
updatePriority();
</script>
Expected output: The navigation shows as many links as fit in the available width. Links that overflow are moved to a "More" dropdown. The number of visible links changes with viewport width.
Code Example: Bottom Navigation Bar
<nav class="bottom-nav" aria-label="Main navigation">
<a href="/" class="bottom-nav-item active" aria-current="page">
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span class="nav-label">Home</span>
</a>
<a href="/search" class="bottom-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<span class="nav-label">Search</span>
</a>
<a href="/cart" class="bottom-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"/>
</svg>
<span class="nav-label">Cart</span>
<span class="nav-badge" aria-label="3 items">3</span>
</a>
<a href="/profile" class="bottom-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<span class="nav-label">Profile</span>
</a>
</nav>
<style>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid #eee;
display: flex;
justify-content: space-around;
align-items: center;
padding: 0.5rem 0 calc(0.5rem + env(safe-area-inset-bottom, 0px));
z-index: 100;
}
.bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 44px;
min-height: 44px;
padding: 4px 8px;
color: #999;
text-decoration: none;
font-size: 0.75rem;
position: relative;
}
.bottom-nav-item.active {
color: #0066CC;
}
.nav-icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
}
.nav-badge {
position: absolute;
top: 0;
right: 4px;
background: #dc3545;
color: #fff;
font-size: 0.625rem;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.bottom-nav {
display: none;
}
}
</style>
Expected output: A sticky bottom navigation bar shows 4 primary actions with icons and labels. Active state highlights the current page. A badge on the cart icon shows item count. Safe area insets prevent notch overlap on modern phones. On desktop, the bottom nav is hidden.
Common Mistakes
- Invisible hamburger on desktop — The hamburger should be hidden on desktop using display:none in a media query.
- No focus management — Opening a menu without moving focus traps keyboard users. Always move focus to the first item.
- Touch targets under 44px — Hamburger buttons and nav links must be at least 44px. Under-sized targets cause tap errors.
- No close button in off-canvas menu — Users expect a visible close button (X) inside the menu panel.
- Body scrolling behind open menu — When the menu is open, prevent body scrolling with overflow: hidden.
- Bottom nav covering content — The bottom nav is fixed. Add padding-bottom to the main content to prevent overlap.
- No safe area handling — Notched phones can hide bottom nav content. Use env(safe-area-inset-bottom).
Practice Questions
- What is the minimum touch target size for navigation links? 44x44 CSS pixels (WCAG 2.5.5).
- What aria attribute indicates whether a menu is open or closed? aria-expanded="true/false" on the toggle button.
- What CSS property prevents body scrolling when a menu is open? overflow: hidden on the body element.
- How do you handle safe areas on notched phones? Use env(safe-area-inset-bottom) as padding.
FAQ
Mini Project
Build 3 navigation patterns for the same site: (1) hamburger menu with off-canvas panel, (2) priority+ navigation that shows up to 4 links and moves the rest to "More", (3) bottom navigation with 4 items including an icon badge. All patterns must: include proper aria labels, manage focus when opening/closing, have 44px touch targets, prevent body scroll when menu is open, handle safe areas, and transition to horizontal navigation on desktop (768px+). Test with keyboard and screen reader.
What's Next
Continue with Lesson 4: Mobile-First Typography to design type for mobile devices.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro