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
- Generated
index.json
with Hugo templates as search index. - Created
search.html
partial for layout, and include it in your base layout. - Wrote
search.js
to load search index and run queries. - Styled it with
search.css
. - Add a search button in your header.
- 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:
- Have front matter, and
- Have
draft: false
(or nodraft
key, which impliesfalse
) 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
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 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 inindex.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, ensurepublic/index.json
exists. - After
hugo server
, hitlocalhost/index.json
to 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.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
:matches the dictionary keys infetch("/index.json") .then(res => res.json()) .then(data => { fuse = new Fuse(data, { keys: ["title", "summary", "contents"], includeScore: true, threshold: 0.3 });
index.json
Check if{{- $.Scratch.Add "index" (dict "title" .Title "summary" .Summary "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
localhost/index.json
is malformed. Validate JSON file after build with a linter.
Comments