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
| State | File | Location | Role |
|---|---|---|---|
| new | index.json template | layouts/_default/index.json |
We 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. |
| new | Client-side search script | static/js/search.js |
Fetch index.json, run queries, render results |
| new | HTML markup for search overlay modal | search-overlay.html |
Added 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. |
| new | Overlay styles | assets/css/search.css |
Provide full‑screen dimmed background, centered input, scrollable results, and smooth transitions. Keeping it separate from main.css makes it modular without theme lock‑in. |
| edit | Config outputs | config.toml or config.yaml |
Ensure 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" falsefilters out any page where.Draftistrue.if .Titleensures 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."contents" .Plainuses the Hugo Page Variable.Plainthat refers to the plain text content of a page. It takes the fully rendered HTML content of a Markdown or content file, removes all the HTML tags and Markdown formatting, resulting in a raw, unformatted text of your post’s body ready for search.
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.
Note that we define our search keys as an array of objects with explicit name and weight properties, instead of a string array like keys: ["title", "summary", "contents"]. This is due to how Fuse.js processes the keys array, especially when mixing short fields (like title and tags) with very long fields (like contents). By providing the keys as a simple array of strings, Fuse.js gives them an equal default weight. Because the content field is so large, a partial match deep inside the content often gets a worse (higher) score than a title match, leading Fuse to prioritize the shorter, higher-confidence matches in the title and effectively ignore the bulk of the content. The result? A keyword found only in our blog post (but not the title) often returns nothing!
To fix this, we explicitly define our keys as objects and assign a lower weight (0.3) to the contents field. In Fuse.js, a lower weight value means a higher importance/impact on the final score. We are telling Fuse.js that matches found are highly significant.
In addition, we also tweak the threshold value, which determines how close the match needs to be (0.0 is a perfect match, 1.0 is a complete mismatch). A low value is often too restrictive for searching long paragraphs of text such as contents. A match deep in the text might have a slightly worse (higher) score due to surrounding noise, capitalization, or minor differences, causing Fuse.js to discard it. We need to make the search slightly more permissive by increasing the threshold to, say, 0.4 or 0.5.
We also
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 => {
// Initialize Fuse.js with explicit key weights
fuse = new Fuse(data, {
// Define keys as objects to assign specific weights.
// A lower 'weight' means that key has a greater impact on the final score.
keys: [
{ name: "title", weight: 0.8 }, // Title is important, but a moderate weight is fine
{ name: "tags", weight: 0.6 }, // Tags are highly relevant
{ name: "contents", weight: 0.3 } // Give contents a low weight for high impact
],
includeScore: true,
// A higher threshold makes the search more permissive, allowing matches deep within the long 'contents' field to be included.
threshold: 0.5
});
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();
}
});
/* Executes the Fuse.js search and renders results to the 'results' element. */
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;
}
// Render the first 10 matches
results.innerHTML = matches
.slice(0, 10)
.map(match => {
const item = match.item;
// Generate a content snippet (first 50 words)
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 = 7Common problems and fixes
- Wrong JSON path
- Symptom: Empty search results; network 404 for
/index.json. - Fix:
- After
hugobuild, ensurepublic/index.jsonexists. - After
hugo server, hitlocalhost/index.jsonto see if it is present. - In
search.js, use absolute path infetch("/index.json").
- After
- Symptom: Empty search results; network 404 for
- Outputs not configured
Symptom:
index.jsonnot 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) insearch.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.jsonis malformed. Validate JSON file after build with a linter.