UI Components Medium
Search Autocomplete
A rich search autocomplete input with categorized results, keyboard navigation, recent searches, and command shortcuts. Similar to Notion or Linear's search.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
/* ── Page layout ── */
.page-center {
width: min(560px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
}
.page-hint {
font-size: 0.8125rem;
color: #475569;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* ── Container ── */
.search-container {
width: 100%;
position: relative;
}
/* ── Input row ── */
.search-input-row {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.875rem;
padding: 0.875rem 1rem;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
cursor: text;
}
.search-container.open .search-input-row {
border-color: rgba(56, 189, 248, 0.4);
background: rgba(56, 189, 248, 0.04);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1);
border-radius: 0.875rem 0.875rem 0 0;
border-bottom-color: transparent;
}
.search-icon {
color: #475569;
flex-shrink: 0;
transition: color 0.15s ease;
}
.search-container.open .search-icon {
color: #38bdf8;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: #f1f5f9;
font-size: 1rem;
font-family: inherit;
caret-color: #38bdf8;
}
.search-input::placeholder {
color: #475569;
}
.shortcut-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.45rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.3rem;
font-size: 0.75rem;
font-family: inherit;
color: #64748b;
white-space: nowrap;
flex-shrink: 0;
}
.shortcut-inline {
font-size: 0.6875rem;
}
/* ── Dropdown ── */
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1e293b;
border: 1px solid rgba(56, 189, 248, 0.35);
border-top: none;
border-radius: 0 0 0.875rem 0.875rem;
overflow: hidden;
z-index: 50;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
animation: dropdownIn 0.15s ease;
}
.search-dropdown[hidden] {
display: none;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Sections ── */
.result-section {
padding: 0.5rem 0;
}
.result-section + .result-section {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.section-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
padding: 0.5rem 1rem 0.375rem;
}
/* ── Result items ── */
.result-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
cursor: pointer;
transition: background 0.1s ease;
outline: none;
}
.result-item:hover,
.result-item.active {
background: rgba(56, 189, 248, 0.08);
}
.result-item.active .result-title {
color: #f1f5f9;
}
.result-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.06);
display: grid;
place-items: center;
flex-shrink: 0;
color: #64748b;
}
.result-item.active .result-icon,
.result-item:hover .result-icon {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
.result-body {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 0.9rem;
font-weight: 500;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-title mark {
background: rgba(56, 189, 248, 0.25);
color: #7dd3fc;
border-radius: 0.2em;
padding: 0 0.1em;
}
.result-category {
font-size: 0.75rem;
color: #475569;
margin-top: 0.1rem;
}
.result-shortcut {
font-size: 0.6875rem;
color: #475569;
display: flex;
gap: 0.2rem;
flex-shrink: 0;
}
.result-shortcut kbd {
padding: 0.15rem 0.4rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.25rem;
font-family: inherit;
}
/* ── Empty state ── */
.empty-state {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 1.5rem 1rem;
color: #475569;
font-size: 0.9rem;
}
.empty-state[hidden] {
display: none;
}
/* ── Footer ── */
.dropdown-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.15);
}
.dropdown-footer #footerHint {
font-size: 0.75rem;
color: #475569;
}
.result-count-badge {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
color: #64748b;
}
.no-recent {
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: #475569;
}
.no-recent[hidden] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.search-dropdown {
animation: none;
}
.result-item {
transition: none;
}
}(function () {
"use strict";
// ── Data ──
const SUGGESTED = [
{
id: "dash",
title: "Dashboard Overview",
category: "Page",
icon: "page",
shortcut: ["⌘", "D"],
},
{ id: "team", title: "Team Members", category: "User", icon: "user", shortcut: null },
{ id: "api", title: "API Documentation", category: "Document", icon: "doc", shortcut: null },
{
id: "revenue",
title: "Revenue Reports",
category: "Chart",
icon: "chart",
shortcut: ["⌘", "R"],
},
{
id: "settings",
title: "Account Settings",
category: "Settings",
icon: "settings",
shortcut: null,
},
{ id: "inbox", title: "Inbox", category: "Page", icon: "page", shortcut: null },
{ id: "projects", title: "Projects", category: "Page", icon: "page", shortcut: null },
{
id: "billing",
title: "Billing & Plans",
category: "Settings",
icon: "settings",
shortcut: null,
},
];
const ICONS = {
page: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
user: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
doc: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 4h16v16H4z"/><line x1="8" y1="9" x2="16" y2="9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="12" y2="17"/></svg>`,
chart: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>`,
settings: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
clock: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
};
const RECENT_KEY = "search-autocomplete-recent";
const MAX_RECENT = 5;
// ── DOM refs ──
const container = document.getElementById("searchContainer");
const input = document.getElementById("searchInput");
const dropdown = document.getElementById("searchDropdown");
const recentSection = document.getElementById("recentSection");
const recentList = document.getElementById("recentList");
const noRecent = document.getElementById("noRecent");
const suggestedSection = document.getElementById("suggestedSection");
const suggestedList = document.getElementById("suggestedList");
const emptyState = document.getElementById("emptyState");
const emptyQuery = document.getElementById("emptyQuery");
const footerHint = document.getElementById("footerHint");
const resultCountBadge = document.getElementById("resultCountBadge");
if (!input) return;
let isOpen = false;
let activeIndex = -1;
let currentItems = []; // flat list of rendered item elements
// ── Recent searches ──
function getRecent() {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
} catch {
return [];
}
}
function addRecent(title) {
let list = getRecent().filter((t) => t !== title);
list.unshift(title);
list = list.slice(0, MAX_RECENT);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
} catch {}
}
// ── Fuzzy filter ──
function fuzzyMatch(str, query) {
const s = str.toLowerCase();
const q = query.toLowerCase();
return s.includes(q);
}
function highlight(title, query) {
if (!query) return title;
const idx = title.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return title;
return (
escapeHtml(title.slice(0, idx)) +
`<mark>${escapeHtml(title.slice(idx, idx + query.length))}</mark>` +
escapeHtml(title.slice(idx + query.length))
);
}
function escapeHtml(s) {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
// ── Render ──
function renderDropdown(query) {
currentItems = [];
activeIndex = -1;
const recent = getRecent();
const filtered = SUGGESTED.filter((item) => !query || fuzzyMatch(item.title, query));
const showEmpty = query.length > 0 && filtered.length === 0;
const showRecent = query.length === 0;
const showSuggested = filtered.length > 0;
// Recent section
recentSection.hidden = !showRecent;
if (showRecent) {
if (recent.length === 0) {
noRecent.hidden = false;
recentList.innerHTML = "";
} else {
noRecent.hidden = true;
recentList.innerHTML = recent
.map(
(title, i) => `
<div class="result-item" data-type="recent" data-value="${escapeHtml(title)}" tabindex="-1" role="option">
<div class="result-icon">${ICONS.clock}</div>
<div class="result-body">
<div class="result-title">${escapeHtml(title)}</div>
<div class="result-category">Recent</div>
</div>
</div>
`
)
.join("");
}
}
// Suggested section
suggestedSection.hidden = !showSuggested;
if (showSuggested) {
suggestedList.innerHTML = filtered
.map((item) => {
const shortcutHtml = item.shortcut
? `<div class="result-shortcut">${item.shortcut.map((k) => `<kbd>${k}</kbd>`).join("")}</div>`
: "";
return `
<div class="result-item" data-type="suggested" data-value="${escapeHtml(item.title)}" data-id="${item.id}" tabindex="-1" role="option">
<div class="result-icon">${ICONS[item.icon] || ICONS.page}</div>
<div class="result-body">
<div class="result-title">${highlight(item.title, query)}</div>
<div class="result-category">${escapeHtml(item.category)}</div>
</div>
${shortcutHtml}
</div>
`;
})
.join("");
}
// Empty state
emptyState.hidden = !showEmpty;
if (showEmpty) emptyQuery.textContent = query;
// Footer
const totalVisible = (showRecent ? recent.length : 0) + (showSuggested ? filtered.length : 0);
resultCountBadge.textContent = `${totalVisible} result${totalVisible !== 1 ? "s" : ""}`;
footerHint.textContent = query
? `Press Enter to search all results for "${query}"`
: "Press Enter to search all";
// Build flat item list for keyboard nav
currentItems = Array.from(dropdown.querySelectorAll(".result-item"));
}
// ── Open / close ──
function openDropdown() {
if (isOpen) return;
isOpen = true;
container.classList.add("open");
input.setAttribute("aria-expanded", "true");
dropdown.hidden = false;
renderDropdown(input.value);
}
function closeDropdown() {
if (!isOpen) return;
isOpen = false;
container.classList.remove("open");
input.setAttribute("aria-expanded", "false");
dropdown.hidden = true;
setActive(-1);
}
function setActive(index) {
currentItems.forEach((el) => el.classList.remove("active"));
activeIndex = index;
if (index >= 0 && index < currentItems.length) {
currentItems[index].classList.add("active");
currentItems[index].scrollIntoView({ block: "nearest" });
}
}
function selectItem(el) {
const value = el.dataset.value;
if (!value) return;
addRecent(value);
input.value = value;
closeDropdown();
input.select();
}
// ── Event listeners ──
input.addEventListener("focus", openDropdown);
input.addEventListener("input", () => {
openDropdown();
renderDropdown(input.value);
});
input.addEventListener("keydown", (e) => {
if (!isOpen) {
if (e.key === "ArrowDown") {
e.preventDefault();
openDropdown();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActive(Math.min(activeIndex + 1, currentItems.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActive(Math.max(activeIndex - 1, -1));
if (activeIndex === -1) input.focus();
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && currentItems[activeIndex]) {
selectItem(currentItems[activeIndex]);
} else if (input.value.trim()) {
addRecent(input.value.trim());
closeDropdown();
}
break;
case "Escape":
e.preventDefault();
closeDropdown();
break;
}
});
dropdown.addEventListener("mousedown", (e) => {
const item = e.target.closest(".result-item");
if (item) {
e.preventDefault();
selectItem(item);
}
});
dropdown.addEventListener("mousemove", (e) => {
const item = e.target.closest(".result-item");
if (item) setActive(currentItems.indexOf(item));
});
document.addEventListener("mousedown", (e) => {
if (!container.contains(e.target)) closeDropdown();
});
// ── Global shortcut ⌘K / Ctrl+K ──
document.addEventListener("keydown", (e) => {
const isMac = navigator.platform.toUpperCase().includes("MAC");
const trigger = isMac ? e.metaKey : e.ctrlKey;
if (trigger && e.key === "k") {
e.preventDefault();
input.focus();
input.select();
openDropdown();
}
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search Autocomplete</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page-center">
<div class="search-container" id="searchContainer">
<!-- Input row -->
<div class="search-input-row">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
type="text"
id="searchInput"
class="search-input"
placeholder="Search anything…"
autocomplete="off"
aria-label="Search"
aria-autocomplete="list"
aria-controls="searchDropdown"
aria-haspopup="listbox"
role="combobox"
aria-expanded="false"
/>
<kbd class="shortcut-badge">⌘K</kbd>
</div>
<!-- Dropdown -->
<div class="search-dropdown" id="searchDropdown" role="listbox" hidden>
<!-- Recent searches section (shown when input empty) -->
<div class="result-section" id="recentSection">
<div class="section-label">Recent Searches</div>
<div id="recentList"></div>
<div class="no-recent" id="noRecent" hidden>No recent searches</div>
</div>
<!-- Suggested section -->
<div class="result-section" id="suggestedSection">
<div class="section-label">Suggested</div>
<div id="suggestedList"></div>
</div>
<!-- Empty state -->
<div class="empty-state" id="emptyState" hidden>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
No results for "<span id="emptyQuery"></span>"
</div>
<!-- Footer -->
<div class="dropdown-footer" id="dropdownFooter">
<span id="footerHint">Press Enter to search all</span>
<span class="result-count-badge" id="resultCountBadge">0 results</span>
</div>
</div>
</div>
<p class="page-hint">Click the search box or press <kbd class="shortcut-badge shortcut-inline">⌘K</kbd> to open</p>
</div>
<script src="script.js"></script>
</body>
</html>Search Autocomplete
A command-palette-style search input with grouped result categories, fuzzy text filtering, and full keyboard navigation. Recent searches are persisted to localStorage and appear when the input is focused but empty.
Features
- Categorized search results with type icons (Page, User, Document, Chart, Settings)
- Recent searches section with clock icon, loaded from and saved to localStorage
- Fuzzy filtering of all results as the user types
- Full keyboard navigation: arrow keys to move, Enter to select, Escape to close
- Keyboard shortcut hint badge showing the
Cmd+K/Ctrl+Ktrigger