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, 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 minimal, modular changes so it works across most setups.

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

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 ensures Hugo will build JSON index of our content. The index is queried by client‑side search scripts to at runtime.
editSearch overlay partiallayouts/partials/baseof.htmlAdded overlay markup for input and results, injected into all pages
newSearch scriptstatic/js/search.jsFetch index, run queries, render results
editOverlay stylesassets/css/main.cssProvide fixed positioning, dimmed background, focus states, and responsive results.
editConfig outputsconfig.toml or config.yamlEnsure JSON is generated and accessible

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 are included. That means, to strictly index only files that:

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

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 that 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

Edit layouts/_default/baseof.html with floating overlay markup (search box, input, close button, results container). It should be added before the </body> tag.

 <body>
    .....
    <!-- 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>
</body>

Or create layouts/partials/search-overlay.html with above code and include the partial in your base layout (e.g., layouts/_default/baseof.html):

{{ partial "search-overlay.html" . }}

Search script

Create assets/js/search.js.

This snippet

  • Prevent form submission from reloading the page.
  • Load /index.json and initialized Fuse.js with the correct keys (title, summary, contents).
  • 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).
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

Edit assets/css/main.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); }
}

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

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

Common problems and fixes

  • Wrong JSON path

    • Symptom: Empty 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 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) -}}
    

    Validate JSON file after build.