Retour au catalogue
Search Command Palette
Palette de commandes CMD+K avec recherche fuzzy, groupes, raccourcis clavier et navigation au clavier.
searchmedium Both Responsive a11y
minimalboldsaassaascentered
Theme
"use client";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
FileText,
Settings,
Users,
BarChart3,
Hash,
ArrowRight,
Command,
CornerDownLeft,
} from "lucide-react";
interface CommandItem {
label: string;
icon: string;
shortcut?: string;
group: string;
description?: string;
}
interface SearchCommandPaletteProps {
commands?: CommandItem[];
placeholder?: string;
}
const iconMap: Record<string, React.ElementType> = {
FileText,
Settings,
Users,
BarChart3,
Hash,
};
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];
export default function SearchCommandPalette({
commands = [],
placeholder = "Tapez une commande ou recherchez...",
}: SearchCommandPaletteProps) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [isOpen, setIsOpen] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
const filtered =
query.length > 0
? commands.filter(
(c) =>
c.label.toLowerCase().includes(query.toLowerCase()) ||
c.description?.toLowerCase().includes(query.toLowerCase())
)
: commands;
const groups = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
if (!acc[cmd.group]) acc[cmd.group] = [];
acc[cmd.group].push(cmd);
return acc;
}, {});
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Escape") {
setIsOpen(false);
}
};
let flatIndex = -1;
return (
<div className="min-h-[500px] flex items-start justify-center pt-16 px-6">
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40"
style={{ background: "var(--color-background)", opacity: 0.6 }}
onClick={() => setIsOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -12 }}
transition={{ duration: 0.2, ease }}
className="relative z-50 w-full max-w-xl rounded-2xl overflow-hidden"
style={{
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
boxShadow: "0 25px 60px -12px rgba(0,0,0,0.3)",
}}
>
{/* Hint bar */}
<div
className="flex items-center justify-between px-4 py-2 text-[11px]"
style={{
borderBottom: "1px solid var(--color-border)",
color: "var(--color-foreground-muted)",
}}
>
<div className="flex items-center gap-1.5">
<Command size={12} />
<span>K pour ouvrir</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<CornerDownLeft size={12} /> Selectionner
</span>
<span>ESC Fermer</span>
</div>
</div>
{/* Search input */}
<div
className="flex items-center gap-3 px-5 py-4"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<Search
size={20}
style={{ color: "var(--color-accent)" }}
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus
className="flex-1 bg-transparent text-base outline-none"
style={{ color: "var(--color-foreground)" }}
/>
{query && (
<motion.button
initial={{ scale: 0 }}
animate={{ scale: 1 }}
onClick={() => setQuery("")}
className="text-xs px-2 py-1 rounded-md"
style={{
background: "var(--color-background-alt)",
color: "var(--color-foreground-muted)",
}}
>
Effacer
</motion.button>
)}
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto py-2">
{Object.entries(groups).map(([group, items]) => (
<div key={group} className="px-2">
<div
className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest"
style={{ color: "var(--color-foreground-light)" }}
>
{group}
</div>
{items.map((cmd) => {
flatIndex++;
const idx = flatIndex;
const Icon = iconMap[cmd.icon] || Hash;
const isSelected = idx === selectedIndex;
return (
<motion.button
key={cmd.label}
layout
className="flex items-center gap-3 w-full px-3 py-3 rounded-xl text-sm transition-all"
style={{
background: isSelected
? "var(--color-accent-subtle)"
: "transparent",
color: isSelected
? "var(--color-foreground)"
: "var(--color-foreground-muted)",
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
background: isSelected
? "var(--color-accent)"
: "var(--color-background-alt)",
}}
>
<Icon
size={16}
style={{
color: isSelected
? "var(--color-background)"
: "var(--color-foreground-muted)",
}}
/>
</div>
<div className="flex-1 text-left">
<div className="font-medium">{cmd.label}</div>
{cmd.description && (
<div
className="text-xs mt-0.5"
style={{ color: "var(--color-foreground-light)" }}
>
{cmd.description}
</div>
)}
</div>
{cmd.shortcut && (
<kbd
className="text-[10px] px-2 py-1 rounded-md font-mono"
style={{
background: "var(--color-background)",
border: "1px solid var(--color-border)",
color: "var(--color-foreground-light)",
}}
>
{cmd.shortcut}
</kbd>
)}
{isSelected && (
<ArrowRight
size={14}
style={{ color: "var(--color-accent)" }}
/>
)}
</motion.button>
);
})}
</div>
))}
{filtered.length === 0 && (
<div
className="py-12 text-center text-sm"
style={{ color: "var(--color-foreground-muted)" }}
>
Aucun resultat pour “{query}”
</div>
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between px-4 py-2.5 text-[11px]"
style={{
borderTop: "1px solid var(--color-border)",
color: "var(--color-foreground-light)",
}}
>
<span>{filtered.length} resultats</span>
<span>Recherche instantanee</span>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{!isOpen && (
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm"
style={{
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
color: "var(--color-foreground-muted)",
}}
>
<Search size={16} />
<span>Rechercher...</span>
<kbd
className="text-[10px] px-1.5 py-0.5 rounded ml-8 font-mono"
style={{
background: "var(--color-background-alt)",
border: "1px solid var(--color-border)",
}}
>
⌘K
</kbd>
</motion.button>
)}
</div>
);
}