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
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 ensures Hugo will build JSON index of our content. The index is queried by client‑side search scripts to at runtime. |
edit | Search overlay partial | layouts/partials/baseof.html | Added overlay markup for input and results, injected into all pages |
new | Search script | static/js/search.js | Fetch index, run queries, render results |
edit | Overlay styles | assets/css/main.css | Provide fixed positioning, dimmed background, focus states, and responsive results. |
edit | Config outputs | config.toml or config.yaml | Ensure 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:
- Have front matter, and
- Have
draft: false
(or nodraft
key, which impliesfalse
),
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
istrue
.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, ensurepublic/index.json
exists. - After
hugo server
, hitlocalhost/index.json
to see if it is present. - In
search.js
, use absolutefetch("/index.json")
.
- After
- Symptom: Empty results; network 404 for
Outputs not configured
- Symptom:
index.json
not generated at all. - Fix: Add JSON outputs in config:
[outputs] home = ["HTML", "JSON"]
- Symptom:
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) -}}
Validate JSON file after build.
Comments