A launcher-style search palette that shows Recent and Suggested sections on focus, then transitions into a flat filtered list as the user types. Arrow keys move across all visible items. Inspired by Raycast.
Recent
Suggested
Empty input shows Recent + Suggested · Type cal or ai to filter
// Dependencies: react ^18, lucide-react
import React, { useState, useEffect, useRef } from 'react';
import {
Search, Clock, Sparkles, Calculator, Cloud, Calendar,
FileText, Music, Globe, ArrowRight,
} from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
type Item = {
id: string;
label: string;
hint: string;
icon: typeof Search;
};
const RECENT: Item[] = [
{ id: 'r-1', label: 'Weather', hint: 'App', icon: Cloud },
{ id: 'r-2', label: 'Calculator', hint: 'App', icon: Calculator },
{ id: 'r-3', label: 'Calendar — Today', hint: 'Action', icon: Calendar },
];
const SUGGESTED: Item[] = [
{ id: 's-1', label: 'Search the web', hint: 'Quick action', icon: Globe },
{ id: 's-2', label: 'Open Notes', hint: 'App', icon: FileText },
{ id: 's-3', label: 'Play Focus playlist', hint: 'Spotify', icon: Music },
{ id: 's-4', label: 'Brainstorm with AI', hint: 'Quick action', icon: Sparkles },
];
const ALL = [...RECENT, ...SUGGESTED];
export function RecentSuggestedSearch() {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const filtered = isSearching
? ALL.filter((i) => i.label.toLowerCase().includes(query.toLowerCase()))
: [];
const visibleList = isSearching ? filtered : [...RECENT, ...SUGGESTED];
useEffect(() => { setSelectedIndex(0); }, [query]);
const listRef = useRef(visibleList);
listRef.current = visibleList;
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, listRef.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);
}, []);
const renderItem = (item: Item, idx: number) => {
const Icon = item.icon;
const isActive = idx === selectedIndex;
return (
<button
key={item.id}
onMouseEnter={() => setSelectedIndex(idx)}
className={cn(
'flex items-center 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="w-7 h-7 rounded-md bg-zinc-800 border border-white/[0.06] flex items-center justify-center shrink-0">
<Icon className="w-3.5 h-3.5 text-zinc-300" />
</span>
<span className="flex-1 text-sm font-medium text-white truncate">
{item.label}
</span>
<span className="text-[11px] text-zinc-500 shrink-0">{item.hint}</span>
{isActive && <ArrowRight className="w-3.5 h-3.5 text-zinc-500 shrink-0" />}
</button>
);
};
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 for apps and commands..."
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">
{isSearching ? (
filtered.length === 0 ? (
<p className="px-4 py-8 text-sm text-zinc-500 text-center">
No matches for “{query}”
</p>
) : (
filtered.map((item, i) => renderItem(item, i))
)
) : (
<>
<div className="mb-2">
<p className="px-4 pt-2 pb-1 text-[10px] font-semibold tracking-widest text-zinc-500 uppercase flex items-center gap-1.5">
<Clock className="w-3 h-3" /> Recent
</p>
{RECENT.map((item, i) => renderItem(item, i))}
</div>
<div>
<p className="px-4 pt-2 pb-1 text-[10px] font-semibold tracking-widest text-zinc-500 uppercase flex items-center gap-1.5">
<Sparkles className="w-3 h-3" /> Suggested
</p>
{SUGGESTED.map((item, i) => renderItem(item, RECENT.length + i))}
</div>
</>
)}
</div>
<div className="flex items-center justify-between px-4 py-2.5 border-t border-white/[0.08] bg-black/20">
<div className="flex items-center gap-4">
<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> run
</span>
</div>
<span className="text-[11px] text-zinc-600">
{isSearching ? `${filtered.length} results` : `${ALL.length} commands`}
</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