Global Search with Results Grouping

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.

Command PalettesInspired by Linear
Live PreviewInteractive
Workspace · Global search

Issues

Docs

People

↑↓navigateopen

Try login · roadmap · Sarah · ↑↓ to navigate across groups

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