Home β€Ί Blog β€Ί Create Dynamic Navigation and Smart Sidebars in Jekyll
Tutorial

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.

Create Dynamic Navigation and Smart Sidebars in Jekyll

Navigation is the skeleton of your site. A visitor who cannot quickly find what they are looking for will leave β€” and a hard-coded navigation menu that requires editing HTML every time you add a page is a maintenance problem waiting to happen. Sidebars that show the same content regardless of which page you are on are a missed opportunity to guide readers.

This guide covers building navigation that is driven by data files (so changes require no HTML edits), active state detection that works reliably across nested URLs, dropdown menus with keyboard accessibility, and sidebars that adapt their content to the current page context.


Part 1: Dynamic navigation with data files

Basic data-driven nav

The first step is to move navigation items out of HTML and into a YAML data file. This makes adding, removing, and reordering pages a one-line change that propagates everywhere.

Create _data/navigation.yml:

main:
  - title: "Themes"
    url: /themes/
  - title: "Blog"
    url: /blog/
  - title: "About"
    url: /about/
  - title: "Contact"
    url: /contact/

In _includes/nav.html, iterate over the data:


<nav class="site-nav" aria-label="Main navigation" role="navigation">
  <ul role="list">
    {% for item in site.data.navigation.main %}
      <li>
        <a href="{{ item.url | relative_url }}"
           {% if page.url == item.url %}
             class="active" aria-current="page"
           {% endif %}>
          {{ item.title }}
        </a>
      </li>
    {% endfor %}
  </ul>
</nav>

To add β€œShowcase” to your nav, edit one line in navigation.yml. You will never need to touch the HTML.


Active state detection that actually works

A simple page.url == item.url check fails for sub-pages. If your Blog nav item points to /blog/ but the reader is on /blog/my-post/, the Blog link will not be highlighted as active. Here is a reliable approach:


{% for item in site.data.navigation.main %}
  {% assign is_active = false %}

  {% comment %} Exact match {% endcomment %}
  {% if page.url == item.url %}
    {% assign is_active = true %}
  {% endif %}

  {% comment %} Parent match β€” active on any sub-page {% endcomment %}
  {% if item.url != '/' and page.url contains item.url %}
    {% assign is_active = true %}
  {% endif %}

  <a href="{{ item.url | relative_url }}"
     {% if is_active %}class="nav__link nav__link--active" aria-current="page"
     {% else %}class="nav__link"{% endif %}>
    {{ item.title }}
  </a>
{% endfor %}

The / exclusion is critical. Without it, the Homepage link would be marked active on every page of the site because every URL contains "/".

For even finer control, you can add an active_section field to your pages’ front matter and match against it in navigation, rather than relying on URL matching. This is useful for pages with unusual URL structures.


Multi-level navigation with dropdowns

Extend the data file to support child items:

# _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: "Showcase"
    url: /showcase/
  - title: "About"
    url: /about/

In your nav include:


<nav class="site-nav" aria-label="Main navigation">
  <ul class="nav__list" role="list">
    {% for item in site.data.navigation.main %}
      <li class="nav__item {% if item.children %}nav__item--has-dropdown{% endif %}">
        
        <a href="{{ item.url | relative_url }}"
           class="nav__link {% if page.url contains item.url and item.url != '/' %}nav__link--active{% endif %}"
           {% if item.children %}
             aria-haspopup="true" 
             aria-expanded="false"
             aria-controls="dropdown-{{ forloop.index }}"
           {% endif %}>
          {{ item.title }}
          {% if item.children %}
            <svg class="nav__arrow" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
              <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.5" fill="none"/>
            </svg>
          {% endif %}
        </a>
        
        {% if item.children %}
          <ul class="nav__dropdown" 
              id="dropdown-{{ forloop.index }}" 
              role="list" 
              aria-label="{{ item.title }} sub-menu">
            {% for child in item.children %}
              <li class="nav__dropdown-item">
                <a href="{{ child.url | relative_url }}"
                   class="nav__dropdown-link {% if page.url == child.url %}nav__dropdown-link--active{% endif %}"
                   {% if page.url == child.url %}aria-current="page"{% endif %}>
                  {{ child.title }}
                </a>
              </li>
            {% endfor %}
          </ul>
        {% endif %}
        
      </li>
    {% endfor %}
  </ul>
</nav>

CSS for the dropdown β€” pure CSS, no JavaScript required for hover:

.nav__item--has-dropdown {
  position: relative;
}

.nav__dropdown {
  position: absolute;
  top: calc(100% + 0.5rem);
  left: 0;
  min-width: 200px;
  background: var(--card-bg);
  border: 1px solid var(--border-color);
  border-radius: var(--radius-md);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
  list-style: none;
  padding: 0.5rem 0;
  margin: 0;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
  z-index: 100;
}

.nav__item--has-dropdown:hover .nav__dropdown,
.nav__item--has-dropdown:focus-within .nav__dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.nav__dropdown-link {
  display: block;
  padding: 0.5rem 1rem;
  white-space: nowrap;
  color: var(--text-color);
  text-decoration: none;

  &:hover {
    background: var(--bg-color);
    color: var(--color-primary);
  }
}

The :focus-within selector makes dropdown navigation fully keyboard-accessible without writing a single line of JavaScript. When a user tabs into the dropdown parent, the submenu becomes visible.


Mobile navigation toggle


<button class="nav-toggle"
        aria-controls="main-nav"
        aria-expanded="false"
        aria-label="Open navigation">
  <span class="hamburger" aria-hidden="true"></span>
</button>

<nav id="main-nav" class="site-nav" aria-label="Main navigation">
  <!-- nav list here -->
</nav>

const toggle = document.querySelector('.nav-toggle');
const nav = document.querySelector('#main-nav');

toggle.addEventListener('click', () => {
  const isOpen = toggle.getAttribute('aria-expanded') === 'true';
  toggle.setAttribute('aria-expanded', String(!isOpen));
  nav.classList.toggle('site-nav--open', !isOpen);
});

// Close when clicking outside
document.addEventListener('click', (e) => {
  if (!nav.contains(e.target) && !toggle.contains(e.target)) {
    toggle.setAttribute('aria-expanded', 'false');
    nav.classList.remove('site-nav--open');
  }
});

// Close on Escape
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    toggle.setAttribute('aria-expanded', 'false');
    nav.classList.remove('site-nav--open');
    toggle.focus();
  }
});

The aria-expanded attribute is essential for screen reader users β€” it announces whether the menu is open or closed. Setting it on the button (not just the nav) is the correct ARIA pattern.


Part 2: Smart sidebars

The context-aware sidebar pattern

A sidebar that shows the same widget on every page wastes valuable real estate. The most useful sidebars change based on what the reader is looking at.

Create a master sidebar include that branches on layout:


<!-- _includes/sidebar.html -->
{% if page.layout == 'post' %}
  {% include sidebar/post-sidebar.html %}
{% elsif page.layout == 'author' %}
  {% include sidebar/author-sidebar.html %}
{% elsif page.layout == 'doc' %}
  {% include sidebar/docs-nav.html %}
{% else %}
  {% include sidebar/default-sidebar.html %}
{% endif %}

In your default layout, include the sidebar:


<div class="page-layout">
  <main class="page-layout__content" id="main-content">
    {{ content }}
  </main>
  <aside class="page-layout__sidebar" aria-label="Sidebar">
    {% include sidebar.html %}
  </aside>
</div>

Each sub-template is a small, focused file that only handles one context. This makes each sidebar easy to modify without affecting the others.


Post sidebar: table of contents and related posts


<!-- _includes/sidebar/post-sidebar.html -->

{% if page.toc %}
<nav class="sidebar-widget sidebar-widget--toc" aria-label="On this page">
  <h3 class="sidebar-widget__title">On This Page</h3>
  {% include toc.html html=content %}
</nav>
{% 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: 5 %}
  {% if related.size == 0 %}
    {% assign related = site.posts
       | where_exp: "p", "p.url != page.url"
       | where_exp: "p", "p.category == page.category"
       | limit: 5 %}
  {% endif %}
  <ul class="sidebar-post-list">
    {% for post in related %}
      <li class="sidebar-post-list__item">
        <a href="{{ post.url }}" class="sidebar-post-list__link">
          {{ post.title }}
        </a>
        <time class="sidebar-post-list__date">
          {{ post.date | date: "%b %Y" }}
        </time>
      </li>
    {% endfor %}
  </ul>
</div>

The related posts logic tries to find posts sharing the first tag. If none are found, it falls back to the same category. This two-tier approach means readers almost always see genuinely related content rather than a generic β€œlatest posts” list.


Documentation sidebar: built from a collection

For documentation sites, generate the sidebar automatically from the docs collection. Each doc has section: and nav_order: front matter:

# _docs/getting-started/installation.md
---
title: Installation
section: Getting Started
nav_order: 1
---

The sidebar template groups docs by section and sorts each section by nav_order:


<!-- _includes/sidebar/docs-nav.html -->
<nav class="docs-nav" aria-label="Documentation navigation">
  {% 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 class="docs-nav__list" role="list">
        {% assign sorted_pages = section.items | sort: "nav_order" %}
        {% for doc in sorted_pages %}
          <li>
            <a href="{{ doc.url | relative_url }}"
               class="docs-nav__link {% if page.url == doc.url %}docs-nav__link--active{% endif %}"
               {% if page.url == doc.url %}aria-current="page"{% endif %}>
              {{ doc.title }}
            </a>
          </li>
        {% endfor %}
      </ul>
    </div>
  {% endfor %}
</nav>

When you add a new documentation page, it appears automatically in the sidebar in the correct section, in the correct position β€” zero maintenance required.


Tag cloud sidebar


<!-- _includes/sidebar/tag-cloud.html -->
<div class="sidebar-widget">
  <h3 class="sidebar-widget__title">Browse Topics</h3>
  <div class="tag-cloud">
    {% assign all_tags = site.tags | sort %}
    {% for tag in all_tags %}
      {% assign count = tag[1].size %}
      {% if count >= 10 %}{% assign size_class = "tag--lg" %}
      {% elsif count >= 5 %}{% assign size_class = "tag--md" %}
      {% else %}{% assign size_class = "tag--sm" %}{% endif %}
      <a href="/tag/{{ tag[0] | downcase | replace: ' ', '-' }}/"
         class="tag {{ size_class }}"
         title="{{ count }} post{% if count != 1 %}s{% endif %}">
        {{ tag[0] }}
      </a>
    {% endfor %}
  </div>
</div>


Keep the sidebar visible as the reader scrolls through long posts β€” no JavaScript needed:

.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
  max-width: var(--container-max-width, 1200px);
  margin: 0 auto;
  padding: 0 1.5rem;

  @media (min-width: 1024px) {
    grid-template-columns: 1fr 280px;
    align-items: start;
  }
}

.page-layout__content {
  min-width: 0; // Prevents grid blowout from wide content like code blocks
}

.page-layout__sidebar {
  @media (min-width: 1024px) {
    position: sticky;
    top: 1.5rem;
    max-height: calc(100vh - 3rem);
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--border-color) transparent;
  }
}

The min-width: 0 on .page-layout__content is one of the most important rules in grid-based layouts. Without it, content that overflows (code blocks, wide tables, images) can force the grid column to grow wider than its allocated fraction, pushing the sidebar off-screen.

position: sticky combined with top: 1.5rem keeps the sidebar in view as the page scrolls. If the sidebar is taller than the viewport, max-height and overflow-y: auto let the sidebar scroll independently β€” readers can scroll both the article and the sidebar separately.


Accessibility checklist

Navigation is one of the most accessibility-critical areas of any site. Before shipping, verify:

  • aria-label on every <nav> element β€” distinguishes your main nav, footer nav, and sidebar nav for screen reader users.
  • aria-current="page" on the active link β€” announces which page is currently loaded.
  • aria-expanded on dropdown toggles β€” announces open/closed state.
  • Keyboard navigation works β€” tab through all nav items, open dropdowns with Enter, close with Escape.
  • Skip-to-content link β€” a visually hidden <a href="#main-content">Skip to content</a> at the top of the page lets keyboard users bypass navigation entirely.

Good navigation is invisible when it works well. Readers find what they need without thinking about the UI. The data-file approach in this guide means you maintain one YAML file and every nav that uses it stays in sync β€” a significant time saving as your site grows. Browse Jekyll themes on JekyllHub to see how different themes approach navigation and sidebar design.

Mega menus and multi-level navigation in Jekyll

Most Jekyll sites need only a flat or two-level navigation structure. Large documentation sites and content-heavy portals occasionally need deeper navigation β€” a mega menu with grouped links, or a persistent sidebar with an expandable tree structure. These can be implemented in pure HTML and CSS with light JavaScript for the expand/collapse behaviour, but the data management approach is where Jekyll can help significantly.

A multi-level navigation structure defined in _data/nav.yml supports nesting naturally in YAML. Top-level items are list entries; each can have a children: key containing another list of nav items. The Liquid template loops over the top level, and for each item with children, loops over the children to render the dropdown or sub-section. This data-first approach means adding or reordering nav sections is a YAML edit, not an HTML edit.

For documentation sidebars with dozens of items organised into sections, Jekyll’s collection front matter provides an elegant alternative to a data file. Each documentation page specifies its parent: section and nav_order: position in its front matter. A sidebar include queries site.pages for all pages with matching parent values, sorts by nav_order, and renders them as a section. New pages automatically appear in the correct section and position without any changes to a separate navigation file. This is how the Just the Docs theme implements its sidebar β€” a pattern worth studying if you are building documentation navigation.

Sites with more than two levels of hierarchy benefit from breadcrumb navigation β€” a trail showing the user’s current position relative to the site’s structure. For a theme marketplace, a breadcrumb might read β€œHome / Themes / Blog” as the user browses the blog category. For a documentation site, it might read β€œDocs / Configuration / _config.yml reference”.

Jekyll does not generate breadcrumbs automatically, but a Liquid include can construct them from page data. For sites using categories, the current post’s category chain serves as the breadcrumb trail. For sites using collections with parent-child relationships, the parent front matter field builds the breadcrumb path. The breadcrumb include is typically placed in the post or page layout, above the article heading, and renders as a short <nav aria-label="Breadcrumb"> element with schema markup for search engines.

Adding BreadcrumbList schema markup to your breadcrumbs (by wrapping the navigation in JSON-LD) qualifies your pages for breadcrumb rich results in Google Search β€” search result listings that show the page hierarchy rather than just the URL, which improves click-through rates particularly for inner pages of multi-level sites.

Mobile navigation patterns for Jekyll themes

Navigation on mobile requires different design decisions than desktop navigation. A horizontal nav bar with dropdowns does not translate to a 375px viewport. The standard patterns are a hamburger menu (a button that toggles a hidden navigation panel), a bottom navigation bar for apps with four or fewer primary sections, and a slide-in drawer from the left or right edge.

The hamburger menu is the most common pattern and works well for sites with five to eight top-level navigation items. A pure CSS hamburger toggle (using a hidden checkbox and the :checked pseudo-class) requires no JavaScript and adds no weight to the page. The trade-off is that pure CSS solutions are harder to extend with animation and less accessible than JavaScript-powered alternatives. A small vanilla JavaScript implementation β€” adding and removing a class on the <body> when the hamburger is tapped β€” is more maintainable and easier to make fully accessible with proper ARIA attributes.

Ensure that your mobile navigation passes keyboard and screen reader testing, not just visual testing. The hamburger button needs an aria-expanded attribute that updates when the menu opens and closes. The navigation panel needs aria-hidden when closed. Focus should be trapped within the open navigation panel and returned to the hamburger button when the panel closes. These requirements are straightforward to implement and ensure that users relying on keyboard or switch access can navigate the site without a mouse.

Measuring navigation effectiveness

Navigation is a functional element β€” its effectiveness can be measured. The key signals are: exit rate on the homepage (high exit rate may indicate visitors cannot find what they are looking for in the navigation), pages-per-session (low values may indicate navigation barriers), and search usage rate (visitors using search frequently may indicate the navigation is not surfacing what they want). Check these metrics in your analytics dashboard after any significant navigation change.

For sites with search functionality, reviewing the most common search queries is the most direct signal about navigation gaps. If visitors frequently search for something that exists in your navigation, the issue is findability β€” the label is wrong, the position is unexpected, or the term you use in the nav does not match what users call the thing. Renaming a nav item based on search query data is one of the fastest wins available in UX optimisation.

Navigation that requires zero thought from visitors β€” where every link is exactly where they expect it to be, labelled with the words they would use β€” is the goal. It is harder to achieve than it appears, and worth iterating on with real usage data rather than designing in isolation.

Share LinkedIn