A global search palette that groups results by type — Issues, Docs, People — with sticky section headers, colored icon tints, and arrow-key navigation across groups. Inspired by Linear's search.
Issues
Docs
People
Try login · roadmap · Sarah · ↑↓ to navigate across groups
// Dependencies: react ^18, lucide-react
import React, { useState, useEffect, useRef } from 'react';
import { Search, CircleDot, FileText, User, ArrowRight } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
type ResultType = 'Issues' | 'Docs' | 'People';
type SearchResult = {
id: string;
type: ResultType;
title: string;
subtitle: string;
};
const RESULTS: SearchResult[] = [
{ id: 'iss-1', type: 'Issues', title: 'Login flow regression on Safari', subtitle: 'ENG-204 · In Progress · Sarah Chen' },
{ id: 'iss-2', type: 'Issues', title: 'Add dark mode toggle to settings', subtitle: 'ENG-211 · Todo · Unassigned' },
{ id: 'iss-3', type: 'Issues', title: 'Search results pagination broken', subtitle: 'ENG-198 · Done · Alex Kim' },
{ id: 'doc-1', type: 'Docs', title: 'API authentication guide', subtitle: 'Updated 2 days ago · Engineering' },
{ id: 'doc-2', type: 'Docs', title: 'Q3 roadmap and OKRs', subtitle: 'Updated 1 week ago · Leadership' },
{ id: 'doc-3', type: 'Docs', title: 'Onboarding checklist for new hires', subtitle: 'Updated 3 hours ago · People Ops' },
{ id: 'ppl-1', type: 'People', title: 'Sarah Chen', subtitle: 'Senior Engineer · Platform team' },
{ id: 'ppl-2', type: 'People', title: 'Marcus Johnson', subtitle: 'Product Designer · Growth team' },
{ id: 'ppl-3', type: 'People', title: 'Priya Patel', subtitle: 'Engineering Manager · Infra team' },
];
const ICONS: Record<ResultType, typeof CircleDot> = {
Issues: CircleDot,
Docs: FileText,
People: User,
};
const ICON_TINTS: Record<ResultType, string> = {
Issues: 'bg-indigo-500/10 text-indigo-300',
Docs: 'bg-amber-500/10 text-amber-300',
People: 'bg-emerald-500/10 text-emerald-300',
};
export function GlobalSearch() {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = RESULTS.filter((r) =>
r.title.toLowerCase().includes(query.toLowerCase()) ||
r.subtitle.toLowerCase().includes(query.toLowerCase())
);
const groups: { type: ResultType; items: SearchResult[] }[] = (
['Issues', 'Docs', 'People'] as ResultType[]
)
.map((type) => ({ type, items: filtered.filter((r) => r.type === type) }))
.filter((g) => g.items.length > 0);
const flat = groups.flatMap((g) => g.items);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const flatRef = useRef(flat);
flatRef.current = flat;
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, flatRef.current.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
let runningIndex = 0;
return (
<div className="w-full max-w-[520px] bg-zinc-900 border border-white/10 rounded-xl shadow-2xl overflow-hidden">
<div className="flex items-center gap-3 px-4 border-b border-white/[0.08]">
<Search className="w-4 h-4 text-zinc-500 shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search issues, docs, people..."
className="flex-1 py-4 bg-transparent text-sm text-white placeholder-zinc-500 outline-none"
/>
</div>
<div className="py-2 max-h-[360px] overflow-y-auto">
{flat.length === 0 ? (
<p className="px-4 py-8 text-sm text-zinc-500 text-center">
No results for “{query}”
</p>
) : (
groups.map((group) => {
const Icon = ICONS[group.type];
return (
<div key={group.type} className="mb-2 last:mb-0">
<p className="px-4 pt-2 pb-1 text-[10px] font-semibold tracking-widest text-zinc-500 uppercase">
{group.type}
</p>
{group.items.map((item) => {
const idx = runningIndex++;
const isActive = idx === selectedIndex;
return (
<button
key={item.id}
onMouseEnter={() => setSelectedIndex(idx)}
className={cn(
'flex items-start gap-3 px-3 py-2 mx-1 rounded-lg text-left transition-colors',
isActive ? 'bg-white/[0.08]' : 'hover:bg-white/[0.04]'
)}
style={{ width: 'calc(100% - 8px)' }}
>
<span className={cn(
'w-7 h-7 rounded-md flex items-center justify-center shrink-0 mt-0.5',
ICON_TINTS[group.type]
)}>
<Icon className="w-3.5 h-3.5" />
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-white truncate">
{item.title}
</span>
<span className="block text-xs text-zinc-500 truncate">
{item.subtitle}
</span>
</span>
{isActive && (
<ArrowRight className="w-3.5 h-3.5 text-zinc-500 shrink-0 mt-1" />
)}
</button>
);
})}
</div>
);
})
)}
</div>
<div className="flex items-center gap-4 px-4 py-2.5 border-t border-white/[0.08] bg-black/20">
<span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
<kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">↑↓</kbd> navigate
</span>
<span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
<kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">↵</kbd> open
</span>
</div>
</div>
);
}Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Inline Data Table
Notion + Linear