Create Dynamic Navigation and Smart Sidebars in Jekyll
Build flexible navigation menus and context-aware sidebars in Jekyll — using data files, active state detection, dropdown menus, and collection-based sidebars.
Hard-coded navigation and static sidebars are the first thing that makes a Jekyll theme feel unpolished. This guide covers building dynamic navigation driven by data files, active state detection that actually works, dropdown menus, and sidebars that adapt to the current page.
Part 1: Dynamic Navigation with Data Files
Basic Data-Driven Nav
Store navigation items in _data/navigation.yml instead of hard-coding them in HTML:
# _data/navigation.yml
main:
- title: "Themes"
url: /themes/
- title: "Blog"
url: /blog/
- title: "About"
url: /about/
- title: "Contact"
url: /contact/
In _includes/nav.html:
<nav class="site-nav" aria-label="Main navigation">
<ul role="list">
{% for item in site.data.navigation.main %}
<li>
<a href="{{ item.url | relative_url }}"
{% if page.url == item.url %}aria-current="page"{% endif %}>
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>
Adding or removing nav items now only requires editing the YAML file — no touching HTML.
Active State Detection That Works
The tricky part is marking the current page. A simple page.url == item.url check fails for sub-pages. Here’s a robust approach:
{% assign current_url = page.url %}
{% assign nav_url = item.url %}
{% comment %}Exact match{% endcomment %}
{% assign is_active = false %}
{% if current_url == nav_url %}
{% assign is_active = true %}
{% endif %}
{% comment %}Parent match — e.g. /blog/ is active when on /blog/my-post/{% endcomment %}
{% if nav_url != '/' and current_url contains nav_url %}
{% assign is_active = true %}
{% endif %}
<a href="{{ nav_url | relative_url }}"
{% if is_active %}class="active" aria-current="page"{% endif %}>
{{ item.title }}
</a>
The / check prevents the homepage link from being active on every page.
Navigation with Sections and Dropdowns
For multi-level navigation, extend the data structure:
# _data/navigation.yml
main:
- title: "Themes"
url: /themes/
children:
- title: "Free Themes"
url: /themes/?type=free
- title: "Premium Themes"
url: /themes/?type=premium
- title: "Blog Themes"
url: /category/blog/
- title: "Portfolio Themes"
url: /category/portfolio/
- title: "Blog"
url: /blog/
- title: "About"
url: /about/
In your nav include:
<nav class="site-nav">
<ul role="list">
{% for item in site.data.navigation.main %}
<li class="{% if item.children %}has-dropdown{% endif %}">
<a href="{{ item.url | relative_url }}"
{% if item.children %}aria-haspopup="true" aria-expanded="false"{% endif %}
{% if page.url contains item.url and item.url != '/' %}class="active"{% endif %}>
{{ item.title }}
{% if item.children %}<span class="dropdown-arrow" aria-hidden="true">▾</span>{% endif %}
</a>
{% if item.children %}
<ul class="dropdown" role="list">
{% for child in item.children %}
<li>
<a href="{{ child.url | relative_url }}"
{% if page.url == child.url %}aria-current="page"{% endif %}>
{{ child.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
CSS for the dropdown:
.has-dropdown {
position: relative;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: opacity 0.2s, transform 0.2s, visibility 0.2s;
z-index: 100;
list-style: none;
padding: 0.5rem 0;
margin: 0;
a {
display: block;
padding: 0.5rem 1rem;
white-space: nowrap;
&:hover {
background: var(--bg-color);
}
}
}
.has-dropdown:hover .dropdown,
.has-dropdown:focus-within .dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
The :focus-within selector makes it keyboard-accessible with no JavaScript.
Mobile Navigation Toggle
<!-- _includes/nav.html -->
<button class="nav-toggle"
aria-controls="main-menu"
aria-expanded="false"
aria-label="Open menu">
<span class="hamburger"></span>
</button>
<nav id="main-menu" class="site-nav" aria-label="Main navigation" hidden>
<!-- nav items -->
</nav>
// Toggle
const toggle = document.querySelector('.nav-toggle');
const menu = document.querySelector('#main-menu');
toggle.addEventListener('click', () => {
const isOpen = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', !isOpen);
menu.hidden = isOpen;
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && !toggle.contains(e.target)) {
toggle.setAttribute('aria-expanded', 'false');
menu.hidden = true;
}
});
Part 2: Smart Sidebars
The Context-Aware Sidebar Pattern
A sidebar that shows different content depending on the page type feels polished. Implement it with a single include that branches on layout:
<!-- _includes/sidebar.html -->
{% if page.layout == 'post' %}
{% include sidebar/post-sidebar.html %}
{% elsif page.layout == 'archive' %}
{% include sidebar/taxonomy-sidebar.html %}
{% elsif page.layout == 'theme' %}
{% include sidebar/theme-sidebar.html %}
{% else %}
{% include sidebar/default-sidebar.html %}
{% endif %}
Then in your layout:
<div class="page-wrapper">
<main class="page-content">{{ content }}</main>
<aside class="sidebar">{% include sidebar.html %}</aside>
</div>
Post Sidebar: Table of Contents + Related Posts
<!-- _includes/sidebar/post-sidebar.html -->
{% if page.toc %}
<div class="sidebar-widget">
<h3 class="sidebar-widget__title">On This Page</h3>
{{ content | toc_only }}
</div>
{% endif %}
<div class="sidebar-widget">
<h3 class="sidebar-widget__title">Related Posts</h3>
{% assign related = site.posts
| where_exp: "p", "p.url != page.url"
| where_exp: "p", "p.tags contains page.tags[0]"
| limit: 4 %}
{% if related.size == 0 %}
{% assign related = site.posts
| where_exp: "p", "p.url != page.url"
| limit: 4 %}
{% endif %}
<ul class="sidebar-post-list">
{% for post in related %}
<li>
<a href="{{ post.url }}">{{ post.title }}</a>
<time>{{ post.date | date: "%b %d" }}</time>
</li>
{% endfor %}
</ul>
</div>
Documentation Sidebar: Auto-Built from Collection
For documentation sites, generate the sidebar from a collection:
<!-- _includes/sidebar/docs-sidebar.html -->
<nav class="docs-nav" aria-label="Documentation">
{% assign sections = site.docs | group_by: "section" | sort: "name" %}
{% for section in sections %}
<div class="docs-nav__section">
<h4 class="docs-nav__heading">{{ section.name }}</h4>
<ul role="list">
{% assign section_pages = section.items | sort: "nav_order" %}
{% for doc in section_pages %}
<li>
<a href="{{ doc.url }}"
{% if page.url == doc.url %}
class="docs-nav__link--active" aria-current="page"
{% else %}
class="docs-nav__link"
{% endif %}>
{{ doc.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</nav>
Each doc in _docs/ has a section: and nav_order: front matter field:
---
title: Installation
section: Getting Started
nav_order: 1
---
The sidebar builds itself automatically as you add documentation pages.
Tag Cloud Sidebar
<!-- _includes/sidebar/tag-cloud.html -->
<div class="sidebar-widget">
<h3 class="sidebar-widget__title">Topics</h3>
<div class="tag-cloud">
{% assign sorted_tags = site.tags | sort %}
{% for tag in sorted_tags %}
{% assign count = tag[1].size %}
{% assign size_class = "tag--sm" %}
{% if count >= 5 %}{% assign size_class = "tag--md" %}{% endif %}
{% if count >= 10 %}{% assign size_class = "tag--lg" %}{% endif %}
<a href="/tag/{{ tag[0] | downcase | replace: ' ', '-' }}/"
class="tag {{ size_class }}"
title="{{ count }} posts">
{{ tag[0] }}
</a>
{% endfor %}
</div>
</div>
Sticky Sidebar
Keep the sidebar visible as the user scrolls through long content:
.sidebar {
@media (min-width: 1024px) {
position: sticky;
top: 1.5rem; // Distance from top of viewport
max-height: calc(100vh - 3rem);
overflow-y: auto;
scrollbar-width: thin;
}
}
This is pure CSS — no JavaScript needed. The sidebar scrolls independently when its content is taller than the viewport.
Putting It Together: Two-Column Layout
.page-wrapper {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
max-width: var(--container-width);
margin: 0 auto;
padding: 0 1rem;
@media (min-width: 1024px) {
grid-template-columns: 1fr 280px;
}
}
.page-content {
min-width: 0; // Prevents grid blowout
}
.sidebar {
@media (min-width: 1024px) {
position: sticky;
top: 1.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
}
min-width: 0 on the content column is critical — without it, wide content like code blocks can blow out the grid.
Well-designed navigation and sidebars make the difference between a site that feels like a polished product and one that feels like a template. Browse Jekyll themes on JekyllHub to see how the best themes handle navigation patterns.