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 &ldquo;{query}&rdquo;
                  </p>
                )}
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </>
  );
}

Avis

Navbar Command Bar — React Navbar Section — Incubator