I love the current Gokarna theme I’m using. But as my site grows, I sorely need search functionality. Instead of starting with a dedicated search page which detours the reader from where they are, I went ahead to create a modern, site‑wide search overlay that can be triggered from anywhere without disrupting what I’m currently viewing.

In this post, I’ll walk you through creating this search overlay with steps that are theme‑agnostic. I will focus on modular and theme‑agnostic changes: partials for markup (HTML), assets for logic (js script) and styles (css), and a clean JSON index.

This guide assumes you already have a Hugo blog up and running , but no search implemented yet.

By the end of this post, you will have

  1. Generated index.json with Hugo templates as search index.
  2. Created search.html partial for layout, and include it in your base layout.
  3. Wrote search.js to load search index and run queries.
  4. Styled it with search.css.
  5. Add a search button in your header.
  6. Searched your existing blog from anywhere!

Files to create or edit

StateFileLocationRole
newindex.json templatelayouts/_default/index.jsonWe need a machine‑readable dataset of posts (title, summary, contents, permalink, URL, date, tags, etc.). This file is generated at build time with a JSON index of our site’s content. The index is queried by client‑side search scripts at runtime.
newClient-side search scriptstatic/js/search.jsFetch index.json, run queries, render results
newHTML markup for search overlay modalsearch-overlay.htmlAdded overlay markup for input and results, injected into all pages. Keeping it in a partial makes it easy to include in a base layout without theme lock‑in.
newOverlay stylesassets/css/search.cssProvide full‑screen dimmed background, centered input, scrollable results, and smooth transitions. Keeping it separate from main.css makes it modular without theme lock‑in.
editConfig outputsconfig.toml or config.yamlEnsure Hugo outputs JSON and includes the right sections. Add search icon/button in header menu to surface search feature.

Implementation snippets

Index JSON template

First we need to build a search index with layouts/_default/index.json.

In my case, since I have a lot of draft posts and supporting files with no frontmatter, I want to exclude these from the index so that only published posts can be searched. That means to strictly index only files that:

  1. Have front matter, and
  2. Have draft: false (or no draft key, which implies false) in front matter.

However, Hugo’s .Draft value can sometimes be false for files without frontmatter. A more reliable way to detect if a page has front matter is to leverage .Site.Pages with a where clause, which checks both draft status and ensures the page has a title (which implies front matter):

{{- $.Scratch.Add "index" slice -}}
{{- range where .Site.RegularPages "Draft" false -}}
  {{- if .Title -}}
    {{- $.Scratch.Add "index" (dict 
      "title" .Title 
      "summary" .Summary 
      "tags" .Params.tags
      "categories" .Params.categories
      "contents" .Plain 
      "permalink" .Permalink) -}}
  {{- end -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}
  • where .Site.RegularPages "Draft" false filters out any page where .Draft is true.
  • if .Title ensures the page has a title, which only exists if front matter is present. Pages without front matter will have an empty .Title, so they’re excluded.

Search overlay HTML

Create layouts/partials/search.html and add this partial in your base layout (e.g., layouts/_default/baseof.html) before the </body> tag:

{{ partial "search.html" . }}

In this way, we separate the search layout from the main theme, so that we can port it elsewhere when we migrate our site to another theme. It also keeps troubleshooting and maintenance easier.

layouts/partials/search.html implements floating overlay markup (search box, input, close button, results container).

<!-- Floating Search Overlay -->
<div id="search-overlay" class="search-overlay">
  <div class="search-box">
    <form>
      <input id="search-query" type="search" placeholder="Type to search..." />
      <button id="close-search" type="button">✖</button>
    </form>
    <div id="search-results"></div>
  </div>
</div>

<!-- Load Fuse.js first -->
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>

<!-- Then load search logic -->
<script src="{{ "js/search.js" | relURL }}"></script>

Search script

Create assets/js/search.js. This snippet:

  • Load /index.json and initialize Fuse.js with the correct keys (title, summary, contents). Make sure these keys match those declared in index.json .
  • Wire up overlay open/close (menu link, close button, backdrop click, ESC key, Ctrl/⌘+K).
  • Render results with title + summary (or fallback to first 50 words of contents).
  • Prevent form submission from reloading the page.
document.addEventListener("DOMContentLoaded", () => {
  const overlay = document.getElementById("search-overlay");
  const input   = document.getElementById("search-query");
  const close   = document.getElementById("close-search");
  const form    = overlay.querySelector("form");
  const results = document.getElementById("search-results");

  let fuse;

  // Load Hugo index
  fetch("/index.json")
    .then(res => res.json())
    .then(data => {
      fuse = new Fuse(data, {
        keys: ["title", "summary", "contents"],
        includeScore: true,
        threshold: 0.3
      });
      console.log("🔎 Fuse.js index loaded:", data.length, "items");
    })
    .catch(err => console.error("Error loading index.json:", err));

  // Prevent form reload
  form.addEventListener("submit", (e) => {
    e.preventDefault();
    runSearch(input.value.trim());
  });

  // Open overlay
  document.querySelectorAll('a[href$="#search"]').forEach(link => {
    link.addEventListener("click", (e) => {
      e.preventDefault();
      overlay.style.display = "flex";
      input.focus();
    });
  });

  // Close overlay
  close.addEventListener("click", () => {
    overlay.style.display = "none";
    results.innerHTML = "";
    input.value = "";
  });

  overlay.addEventListener("click", (e) => {
    if (e.target === overlay) overlay.style.display = "none";
  });

  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && overlay.style.display === "flex") {
      overlay.style.display = "none";
    }
    const isMeta = e.ctrlKey || e.metaKey;
    if (isMeta && e.key.toLowerCase() === "k") {
      e.preventDefault();
      overlay.style.display = "flex";
      input.focus();
    }
  });

  // Search + render
  function runSearch(query) {
    if (!fuse || !query) {
      results.innerHTML = "<p>No results.</p>";
      return;
    }

    const matches = fuse.search(query);
    if (matches.length === 0) {
      results.innerHTML = "<p>No results found.</p>";
      return;
    }

    results.innerHTML = matches
      .slice(0, 10)
      .map(match => {
        const item = match.item;
        const snippet = item.summary || item.contents.split(/\s+/).slice(0,50).join(" ") + "…";
        return `
          <div class="search-result">
            <h3><a href="${item.permalink}">${item.title}</a></h3>
            <p>${snippet}</p>
          </div>
        `;
      })
      .join("");
  }
});

Overlay CSS styles

Again, we will use a modular CSS Structure in the assets/css/ directory:

assets/ └── css/ ├── main.css // Contains general styles └── search.css // Specific styles for the search feature

We will then use a single entry point in layouts/partials/head.html that assembles component CSS files from assets/ into a single file. This keeps CSS modular at source, and request is combined into a single fetch in web serving.

First, create assets/css/search.css. It’s the visual backbone of our overlay search feature. Here’s how each part contributes:

.search-overlay

  • Positions the overlay fullscreen and above all other content (z-index: 9999)
  • Applies the translucent dark backdrop
  • Starts hidden (display: none) and is toggled via JavaScript

.search-box

  • Styles the floating white search container
  • Adds padding, rounded corners, and a subtle animation (fadeInUp)
  • Ensures it’s centered and visually distinct

.search-box form, input, button

  • Aligns the input and close button horizontally
  • Makes the input usable and readable
  • Styles the close button with hover feedback

#search-results, .search-result, h3, p

  • Controls layout and spacing of search results
  • Ensures titles and snippets are readable and cleanly separated

@keyframes fadeInUp

  • Adds a smooth entrance animation to the search box
/* Floating Search Overlay */
.search-overlay {
  position: fixed;
  top: 0; left: 0;
  width: 100%; height: 100%;
  background: rgba(0,0,0,0.6); /* translucent dark backdrop */
  display: none;               /* hidden by default */
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.search-box {
  background: rgba(255,255,255,0.95); /* translucent white box */
  padding: 2rem;
  border-radius: 8px;
  width: 80%;
  max-width: 600px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
  animation: fadeInUp 0.25s ease-out;
}

.search-box form {
  display: flex;
  align-items: center;
  margin-bottom: 1rem;
}

.search-box input[type="search"] {
  flex: 1;
  padding: 0.75rem;
  font-size: 1.1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.search-box button#close-search {
  margin-left: 0.5rem;
  padding: 0.5rem 0.75rem;
  font-size: 1rem;
  background: #eee;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.search-box button#close-search:hover {
  background: #ddd;
}

#search-results {
  max-height: 300px;
  overflow-y: auto;
}

.search-result {
  margin-bottom: 1rem;
}

.search-result h3 {
  margin: 0 0 0.25rem;
  font-size: 1.1rem;
}

.search-result p {
  margin: 0;
  color: #555;
  font-size: 0.95rem;
}

/* Simple fade-in animation */
@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}

Instead of a simple: <link rel="stylesheet" href="{{ "css/main.css" | relURL }}"> in layouts/partials/head.html to load a single main.css, we now need to load all our modular CSS files. We will use Hugo Pipes’ resources.Get and resources.Concat functions to load each partial CSS file from assets and then produce a single output file that our layout references.

Edit layouts/partials/head.html:

{{- $styles := resources.Get "css/main.css" -}}
{{- $search_styles := resources.Get "css/search.css" -}}
 
{{- /* Combine the files, giving the output file a unique name */ -}}
{{- $all_styles := (slice $styles $search_styles ) | resources.Concat "css/bundle.css" -}}

{{- /* Optionally, add PostCSS, Sass, or Minification steps */ -}}
{{- $all_styles := $all_styles | resources.Minify | resources.Fingerprint -}}

<link rel="stylesheet" href="{{ $all_styles.RelPermalink }}">

This collects specified files from assets/, concatenates, minifies and fingerprints the bundle, then links it in the page.

Config.toml

Finally, edit the site configuration to enable JSON generation.

[outputs]
  home = ["HTML", "RSS", "JSON"]

I also added a pointer from the menu bar to surface the search feature.

    [[menu.main]]
    name = ""
    pre = "<span data-feather='search'></span>"
    url = "#search"   
    weight = 7

Common problems and fixes

  • Wrong JSON path

    • Symptom: Empty search results; network 404 for /index.json.
    • Fix:
      • After hugo build, ensure public/index.json exists.
      • After hugo server, hit localhost/index.json to see if it is present.
      • In search.js, use absolute path in fetch("/index.json") .
  • Outputs not configured

    • Symptom: index.json not generated at all.
    • Fix: Add JSON outputs in config:
      [outputs]
      home = ["HTML", "JSON"]
      
  • JSON shape mismatch

    • Symptom: Script errors or missing fields.
    • Fix: Make sure the keys (e.g., title, summary, url, date, tags) in search.js:
      fetch("/index.json")
        .then(res => res.json())
        .then(data => {
          fuse = new Fuse(data, {
            keys: ["title", "summary", "contents"],
            includeScore: true,
            threshold: 0.3
          });
      
      matches the dictionary keys in index.json
      {{- $.Scratch.Add "index" (dict 
      "title" .Title 
      "summary" .Summary 
      "tags" .Params.tags
      "categories" .Params.categories
      "contents" .Plain 
      "permalink" .Permalink) -}}
      
      Check if localhost/index.json is malformed. Validate JSON file after build with a linter.