Retour au catalogue
Navbar Command Bar
Navbar avec barre de recherche/commande style Cmd+K integree. Palette flottante au clic.
navbarcomplex Both Responsive a11y
minimalcorporatesaasuniversalsticky
Theme
"use client";
import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Search, Command, X, ArrowRight } from "lucide-react";
interface NavLink {
label: string;
href: string;
}
interface CommandItem {
label: string;
href: string;
category?: string;
}
interface NavbarCommandBarProps {
brandName?: string;
links?: NavLink[];
ctaLabel?: string;
ctaUrl?: string;
commandItems?: CommandItem[];
searchPlaceholder?: string;
}
const EASE = [0.16, 1, 0.3, 1] as const;
export default function NavbarCommandBar({
brandName = "Brand",
links = [],
ctaLabel = "Contact",
ctaUrl = "#contact",
commandItems = [],
searchPlaceholder = "Rechercher...",
}: NavbarCommandBarProps) {
const [commandOpen, setCommandOpen] = useState(false);
const [query, setQuery] = useState("");
const toggleCommand = useCallback(() => {
setCommandOpen((prev) => !prev);
setQuery("");
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
toggleCommand();
}
if (e.key === "Escape" && commandOpen) {
setCommandOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [commandOpen, toggleCommand]);
const filtered = query
? commandItems.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
)
: commandItems;
const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, item) => {
const cat = item.category ?? "Resultats";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
return (
<>
<header
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
zIndex: 50,
background: "var(--color-background)",
borderBottom: "1px solid var(--color-border)",
}}
>
<nav
style={{
width: "100%",
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
height: "4rem",
gap: "1.5rem",
}}
>
<a
href="/"
style={{
fontSize: "1.125rem",
fontWeight: 700,
color: "var(--color-foreground)",
textDecoration: "none",
letterSpacing: "-0.02em",
flexShrink: 0,
}}
>
{brandName}
</a>
{/* Search bar trigger */}
<button
onClick={toggleCommand}
style={{
display: "none",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 1rem",
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
background: "var(--color-background-alt)",
cursor: "pointer",
flex: "0 1 360px",
minWidth: 0,
}}
className="md:!flex"
>
<Search style={{ width: 14, height: 14, color: "var(--color-foreground-muted)", flexShrink: 0 }} />
<span style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)", flex: 1, textAlign: "left" }}>
{searchPlaceholder}
</span>
<kbd
style={{
display: "inline-flex",
alignItems: "center",
gap: "2px",
padding: "0.125rem 0.375rem",
borderRadius: "4px",
border: "1px solid var(--color-border)",
background: "var(--color-background)",
fontSize: "0.6875rem",
color: "var(--color-foreground-muted)",
flexShrink: 0,
}}
>
<Command style={{ width: 10, height: 10 }} />K
</kbd>
</button>
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem", flexShrink: 0 }}>
<div
style={{ display: "none", alignItems: "center", gap: "1.5rem" }}
className="lg:!flex"
>
{links.map((link) => (
<a
key={link.href}
href={link.href}
style={{
fontSize: "0.875rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
{link.label}
</a>
))}
</div>
<a
href={ctaUrl}
style={{
display: "inline-flex",
alignItems: "center",
padding: "0.5rem 1.25rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-foreground)",
fontWeight: 600,
fontSize: "0.8125rem",
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
{ctaLabel}
</a>
</div>
</nav>
</header>
{/* Command palette */}
<AnimatePresence>
{commandOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setCommandOpen(false)}
style={{ position: "fixed", inset: 0, zIndex: 99, background: "rgba(0,0,0,0.5)" }}
/>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: -8 }}
transition={{ duration: 0.2, ease: EASE }}
style={{
position: "fixed",
top: "20%",
left: "50%",
transform: "translateX(-50%)",
width: "90vw",
maxWidth: "520px",
zIndex: 100,
borderRadius: "var(--radius-xl)",
background: "var(--color-background)",
border: "1px solid var(--color-border)",
boxShadow: "0 20px 60px -12px rgba(0,0,0,0.25)",
overflow: "hidden",
}}
>
{/* Search input */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1.25rem",
borderBottom: "1px solid var(--color-border)",
}}
>
<Search style={{ width: 16, height: 16, color: "var(--color-foreground-muted)", flexShrink: 0 }} />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
autoFocus
style={{
flex: 1,
border: "none",
background: "transparent",
fontSize: "0.9375rem",
color: "var(--color-foreground)",
outline: "none",
}}
/>
<button
onClick={() => setCommandOpen(false)}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
color: "var(--color-foreground-muted)",
padding: 0,
}}
>
<X style={{ width: 16, height: 16 }} />
</button>
</div>
{/* Results */}
<div style={{ maxHeight: "320px", overflowY: "auto", padding: "0.5rem" }}>
{Object.entries(grouped).map(([category, items]) => (
<div key={category} style={{ marginBottom: "0.5rem" }}>
<p
style={{
fontSize: "0.6875rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--color-foreground-muted)",
padding: "0.5rem 0.75rem 0.25rem",
}}
>
{category}
</p>
{items.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setCommandOpen(false)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0.75rem",
borderRadius: "var(--radius-md)",
fontSize: "0.875rem",
color: "var(--color-foreground)",
textDecoration: "none",
}}
>
{item.label}
<ArrowRight style={{ width: 12, height: 12, color: "var(--color-foreground-muted)" }} />
</a>
))}
</div>
))}
{filtered.length === 0 && (
<p
style={{
textAlign: "center",
padding: "2rem 1rem",
fontSize: "0.875rem",
color: "var(--color-foreground-muted)",
}}
>
Aucun resultat pour “{query}”
</p>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}