Ctrl+K for a Static Site
Try it now, if you're on a keyboard: press Ctrl+K (Cmd+K on a Mac), or just /. A search box appears over the page. Type "abandoned" and you'll get the blog post about ruins and the gallery filtered to abandoned buildings; type "map" and you're one Enter away from the photo map. Every page, every post, and every photo tag on this site is reachable from that box, from anywhere. It's the interface programmers know from their editors, transplanted onto a personal website, and this post explains how little it took: 217 lines of vanilla JavaScript, no search service, no library. Part of the series on how this site is built.
Quick jargon guide
- Command palette: a keyboard-summoned search box that finds and jumps to anything. Popularised by code editors; now in Slack, Notion, GitHub, and (as of some point) here.
- Fuzzy matching: matching that forgives imprecision: "phto" still finds "photography", because the letters appear in order.
- Focus trap: keeping keyboard focus inside a dialog while it's open, so Tab doesn't wander off into the page behind it.
- Deep link: a URL that opens a page in a specific state, like the gallery pre-filtered to one tag.
What it searches, and what it doesn't
The index has three sources, in descending order of hardcoded-ness. First, the site's ten pages, written directly into the script with a subtitle each ("490+ photos", "571 saved passages"); they change a few times a year, so a hardcoded list is simply correct. Second, every blog post, fetched from the same JSON file that runs the blog listing, so the palette knows each post's title, tags, and excerpt without any separate index to maintain; publish a post, and the palette knows it. Third, the gallery's ten photo tags, each deep-linking to the gallery pre-filtered, which is what makes "wildlife" a destination rather than a word.
The palette doesn't search inside post text, and it doesn't index the 571 quotes or 490 photos individually. Full-text search of a static site is doable (ship an index file, use a library like Lunr) but the index grows with every word you write, and I certainly wouldn't want paragraph-level results on a personal site. Titles, tags, and excerpts answer "take me to the thing I half-remember". The quote wall also gets one entry, not 571.
Fuzzy
Real fuzzy-search libraries use sophisticated string mathematics. Mine uses a five-tier ladder: an exact match scores 120, a match at the start of the text 100, a match at a word boundary 80, a match anywhere 60, and letters-in-the-right-order (the "phto" case) 25. Each source gets a weighting on top (a title match beats a tag match beats an excerpt match), everything above zero is sorted, and the top ten render.
Arrow keys move the selection and wrap at the ends; Tab is trapped so focus can't escape the dialog; Escape closes; a screen-reader announcement reports the result count as you type; and the input is focused synchronously on opening, because mobile keyboards only appear if the focus happens inside the user's gesture.
Why bother, on a site this small?
Part of the answer is who visits: this site's readers skew technical, their muscle memory already knows Ctrl+K. Part is that the palette compounds with the site's growth: at ten pages it's a nicety, at 46 posts it's the fastest way to find anything, and every post published makes it slightly more useful at zero marginal cost. And part of it, in the spirit of candour that runs through this series: it's the kind of detail that's simply pleasing to have on one's own website. It scratches an itch for me, and it was fun to implement.
One keydown listener for the shortcut, a dialog built on first open (not before; most visitors never press it), a scoring function, and a list that re-renders per keystroke. Resist the URL that offers to do it for you as a hosted service; for a site whose whole index fits in one JSON fetch, the dependency would outweigh the feature.
Take the code
The whole thing is below, copy-paste ready. The JavaScript is the complete js/palette.js from this site: swap the PAGES array for your own pages, point the two fetch calls at whatever JSON you have (or delete them, along with the sources they feed), and it will run on any static site with no build step. The CSS is the full set of palette styles, dark theme included; the [data-theme="dark"] selectors assume a theme attribute on <html>, so adapt those to however your site handles dark mode.
The JavaScript (js/palette.js, 217 lines)
/**
* palette.js — site-wide command palette (Ctrl/Cmd+K, or '/').
*
* Fuzzy search across pages, blog posts (data/posts.json), photo tags,
* and map places (deep links to gallery.html?tag=... and map.html?region=...).
* Vanilla JS, builds its DOM on first open, loaded with defer on every page.
*/
(() => {
const prefix = /\/blog\//.test(window.location.pathname) ? '../' : './';
const PAGES = [
{ title: 'Home', sub: 'Intro and latest posts', url: 'index.html' },
{ title: 'About', sub: 'Who I am', url: 'about.html' },
{ title: 'Data Science', sub: 'Projects & publications', url: 'data_science.html' },
{ title: 'Photography', sub: '490+ photos', url: 'gallery.html' },
{ title: 'Photo Map', sub: 'Photographs by place', url: 'map.html' },
{ title: 'Music', sub: 'Guitar & listening stats', url: 'music.html' },
{ title: 'Literature', sub: 'Reviews & reading stats', url: 'literature.html' },
{ title: 'Quote Wall', sub: '571 saved passages', url: 'quotes.html' },
{ title: 'Blog', sub: 'All posts', url: 'blog.html' },
{ title: 'Contact', sub: 'Get in touch', url: 'contact.html' }
];
const PHOTO_TAGS = ['wildlife', 'portrait', 'bw', 'architecture', 'abandoned',
'urban', 'nature', 'silhouette', 'landscape', 'winter'];
let overlay = null;
let input = null;
let list = null;
let items = [];
let active = 0;
let posts = null;
let places = null;
let indexRequested = false;
// Score ladder: exact 120, prefix 100, word boundary 80, anywhere 60,
// letters-in-the-right-order 25, no match 0.
function score(query, text) {
if (!text) return 0;
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return 120;
const idx = t.indexOf(q);
if (idx === 0) return 100;
if (idx > 0) return t[idx - 1] === ' ' ? 80 : 60;
let ti = 0;
for (const ch of q) {
ti = t.indexOf(ch, ti);
if (ti === -1) return 0;
ti++;
}
return 25;
}
function collectResults(query) {
const q = query.trim();
const results = [];
const add = (s, kind, title, sub, href) => {
if (s > 0) results.push({ score: s, kind, title, sub, href });
};
// With an empty query the palette shows the pages as a menu.
for (const p of PAGES) {
add(q ? Math.max(score(q, p.title), score(q, p.sub) * 0.5) : 10,
'Page', p.title, p.sub, prefix + p.url);
}
if (q) {
for (const p of posts || []) {
const tags = (p.tags || []).join(' ');
add(Math.max(score(q, p.title), score(q, tags) * 0.8, score(q, p.excerpt || '') * 0.4),
'Post', p.title, (p.tags || []).join(' · '), prefix + (p.url || ''));
}
for (const t of PHOTO_TAGS) {
add(score(q, t) * 0.9, 'Photos', `${t[0].toUpperCase()}${t.slice(1)} photos`,
'Gallery filter', `${prefix}gallery.html?tag=${t}`);
}
for (const pl of places || []) {
add(score(q, pl.name) * 0.9, 'Place', pl.name,
`${pl.count} photo${pl.count === 1 ? '' : 's'} on the map`,
`${prefix}map.html?region=${encodeURIComponent(pl.name)}`);
}
}
return results.sort((a, b) => b.score - a.score).slice(0, 10);
}
function render(query) {
items = collectResults(query);
active = 0;
overlay.querySelector('.kr-palette-live').textContent =
items.length ? `${items.length} result${items.length === 1 ? '' : 's'}` : 'No results';
list.innerHTML = items.length
? items.map((r, i) =>
`<a href="${r.href}" class="kr-palette-item${i === 0 ? ' active' : ''}" data-i="${i}">` +
`<span class="kr-palette-kind">${r.kind}</span>` +
`<span class="kr-palette-text"><span class="kr-palette-title">${r.title}</span>` +
(r.sub ? `<span class="kr-palette-sub">${r.sub}</span>` : '') +
'</span></a>').join('')
: '<div class="kr-palette-empty">No matches. Try a post title, page, photo tag, or place.</div>';
}
function setActive(i) {
const els = list.querySelectorAll('.kr-palette-item');
if (!els.length) return;
active = (i + els.length) % els.length;
els.forEach((el, j) => el.classList.toggle('active', j === active));
els[active].scrollIntoView({ block: 'nearest' });
}
function build() {
overlay = document.createElement('div');
overlay.className = 'kr-palette-overlay';
overlay.innerHTML =
'<div class="kr-palette" role="dialog" aria-modal="true" aria-label="Site search">' +
'<input type="text" class="kr-palette-input" placeholder="Search posts, pages, photos…" aria-label="Search site" role="combobox" aria-expanded="true" aria-autocomplete="list">' +
'<div class="kr-palette-list" role="listbox"></div>' +
'<div class="kr-palette-live sr-only" aria-live="polite"></div>' +
'<div class="kr-palette-foot"><span>↑↓ navigate</span><span>↵ open</span><span>esc close</span></div>' +
'</div>';
document.body.appendChild(overlay);
input = overlay.querySelector('.kr-palette-input');
list = overlay.querySelector('.kr-palette-list');
overlay.addEventListener('mousedown', (e) => {
if (e.target === overlay) close();
});
input.addEventListener('input', () => render(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active - 1); }
else if (e.key === 'Tab') {
// Focus trap: the input is the palette's single focus stop;
// Tab moves the selection instead of leaving the dialog.
e.preventDefault();
setActive(active + (e.shiftKey ? -1 : 1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (items[active]) window.location.href = items[active].href;
} else if (e.key === 'Escape') close();
});
list.addEventListener('mousemove', (e) => {
const el = e.target.closest('.kr-palette-item');
if (el) setActive(Number(el.dataset.i));
});
}
function loadIndex() {
if (indexRequested) return;
indexRequested = true;
const grab = (url, apply) => fetch(prefix + url)
.then((r) => r.json())
.then((data) => { apply(data); render(input.value); })
.catch(() => { /* source stays empty; pages still work */ });
grab('data/posts.json', (data) => { posts = data; });
grab('data/photo-locations.json', (data) => {
places = (data.regions || []).map((r) => ({ name: r.name, count: (r.photos || []).length }));
});
}
function open() {
if (!overlay) build();
loadIndex();
overlay.classList.add('is-open');
document.body.classList.add('kr-palette-open');
input.value = '';
render('');
// Focus synchronously: mobile browsers only raise the soft keyboard
// when focus happens inside the user gesture.
input.focus();
setTimeout(() => input.focus(), 30);
}
function close() {
overlay.classList.remove('is-open');
document.body.classList.remove('kr-palette-open');
}
const isOpen = () => Boolean(overlay) && overlay.classList.contains('is-open');
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
if (isOpen()) close(); else open();
} else if (e.key === '/' && !isOpen()) {
const t = e.target;
const typing = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
if (!typing) { e.preventDefault(); open(); }
} else if (e.key === 'Escape' && isOpen()) {
close();
}
});
// Nav hint: the header is injected by shared-components.js, so wait
// for #nav to exist before appending the Search pill.
document.addEventListener('DOMContentLoaded', () => {
const tryInsert = () => {
const nav = document.getElementById('nav');
if (!nav || document.querySelector('.kr-palette-hint')) return Boolean(nav);
const li = document.createElement('li');
li.innerHTML = '<a href="#" class="kr-palette-hint" role="button" aria-label="Search the site (Ctrl+K)">' +
'<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/></svg>' +
'<span class="kr-palette-word">Search</span>' +
'<span class="kr-palette-kbd">Ctrl K</span></a>';
li.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
open();
});
nav.appendChild(li);
return true;
};
if (!tryInsert()) {
const mo = new MutationObserver(() => {
if (tryInsert()) mo.disconnect();
});
mo.observe(document.body, { childList: true, subtree: true });
}
});
})();
The CSS (147 lines)
/* Command palette (Ctrl/Cmd+K — js/palette.js) */
.kr-palette-overlay {
position: fixed;
inset: 0;
z-index: 6000;
background: rgba(10, 10, 10, 0.55);
backdrop-filter: blur(3px);
display: none;
align-items: flex-start;
justify-content: center;
padding: 12vh 16px 0; }
.kr-palette-overlay.is-open {
display: flex; }
body.kr-palette-open {
overflow: hidden; }
.kr-palette {
width: 100%;
max-width: 560px;
background: #ffffff;
border: 1px solid #e2e2e2;
border-radius: 14px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
overflow: hidden; }
.kr-palette-input {
width: 100%;
border: 0;
outline: none;
background: transparent;
padding: 18px 20px;
font-size: 17px;
font-family: "Poppins", sans-serif;
color: #252525;
border-bottom: 1px solid #ececec;
box-shadow: none !important; }
.kr-palette-list {
max-height: 46vh;
overflow-y: auto;
padding: 8px; }
.kr-palette-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
text-decoration: none; }
.kr-palette-item.active {
background: rgba(252, 96, 96, 0.1); }
.kr-palette-kind {
flex-shrink: 0;
width: 52px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #999; }
.kr-palette-text {
display: flex;
flex-direction: column;
min-width: 0; }
.kr-palette-title {
font-size: 14.5px;
font-weight: 500;
color: #252525;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.kr-palette-item.active .kr-palette-title {
color: #c53030; }
.kr-palette-sub {
font-size: 12px;
color: #8a8a8a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; }
.kr-palette-empty {
padding: 24px 16px;
text-align: center;
font-size: 14px;
color: #8a8a8a; }
.kr-palette-foot {
display: flex;
gap: 18px;
justify-content: center;
padding: 10px;
border-top: 1px solid #ececec;
font-size: 11px;
color: #9a9a9a; }
[data-theme="dark"] .kr-palette {
background: #222222;
border-color: #3a3a3a; }
[data-theme="dark"] .kr-palette-input {
color: #e2e2e2;
border-bottom-color: #333; }
[data-theme="dark"] .kr-palette-item.active {
background: rgba(252, 96, 96, 0.14); }
[data-theme="dark"] .kr-palette-title {
color: #e2e2e2; }
[data-theme="dark"] .kr-palette-item.active .kr-palette-title {
color: #fc6060; }
[data-theme="dark"] .kr-palette-foot {
border-top-color: #333; }
/* Nav hint pill */
.kr-palette-hint {
display: inline-flex;
align-items: center;
gap: 7px; }
.kr-palette-kbd {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.05em;
border: 1px solid currentColor;
border-radius: 5px;
padding: 1px 6px;
opacity: 0.75; }
.kr-palette-word {
display: none; }
/* In the mobile hamburger menu the hint becomes a plain "Search" item
(keyboard shortcut label makes no sense on touch) */
@media (max-width: 991px) {
.kr-palette-kbd {
display: none; }
.kr-palette-word {
display: inline; } }
Common questions
Why Ctrl+K and not Ctrl+F?
Ctrl+F belongs to the browser, and hijacking it is hostile: someone searching within the page they're reading should get exactly that. Ctrl+K is the established palette convention (editors, Slack, GitHub), and the bare / covers the other established convention (Wikipedia, YouTube). Never take shortcuts the browser already spent on something people use.
Does it work on phones?
Yes, via a search button in the navigation rather than a keyboard shortcut, with the same dialog underneath. Palettes are a keyboard-first idea, so on touch it's simply a decent search box, which is fine: the feature degrades into usefulness rather than absence.
Why not index the full text of posts?
Cost-benefit: a full-text index of 46 posts is a few hundred kilobytes shipped to everyone, to serve queries that title-and-excerpt matching already answers. If the blog someday holds hundreds of posts and I'm personally failing to find things, a pre-built Lunr index is the upgrade path. The palette's job today is navigation, not research.
Is 217 lines really the whole thing?
The whole behaviour, yes: shortcut, dialog, scoring, keyboard navigation, accessibility announcements. The styling is another 147 lines of CSS, included above. Most of what feels substantial about a palette is borrowed: the pages already exist, the posts file already exists, and the deep links already work. It's a thin, fast layer over structure the site already had, which is the recurring theme of every post in this series.
