Recent + Suggested Search

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.

Command PalettesInspired by Raycast
Live PreviewInteractive
Launcher · Recent + Suggested

Recent

Suggested

↑↓navigaterun
7 commands

Empty input shows Recent + Suggested · Type cal or ai to filter

RecentSuggestedSearch.tsx
// 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 &ldquo;{query}&rdquo;
            </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>
  );
}