React + MotionLiquid Glass

Recherche Spotlight

Une palette de commandes ⌘K en verre : les résultats arrivent en cascade et se parcourent aux flèches.

Réglages

20px
12%
25%

Code

import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Airplay, Calendar, Compass, FileText, Folder, Image as ImageIcon, Moon, Music2, Search, Settings, type LucideIcon } from "lucide-react";

type Wallpaper = "aurora" | "sunset" | "ocean" | "light";

const spring = { type: "spring" as const, stiffness: 400, damping: 30 };

const walls: Record<Wallpaper, { base: string; blobs: string[] }> = {
  aurora: { base: "#0b0b1a", blobs: ["#5b21b6", "#2563eb", "#ec4899"] },
  sunset: { base: "#1a0b14", blobs: ["#f97316", "#f43f5e", "#8b5cf6"] },
  ocean: { base: "#031a1f", blobs: ["#06b6d4", "#3b82f6", "#10b981"] },
  light: { base: "#f8fafc", blobs: ["#fed7aa", "#ddd6fe", "#bae6fd"] },
};

const blobPositions = [
  ["-10%", "-15%"],
  ["45%", "5%"],
  ["5%", "50%"],
];

const grain = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";

const glassCss = `
.lg-font {
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.lg-glass {
  position: relative;
  overflow: hidden;
  background: rgba(255, 255, 255, var(--lg-a, 0.12));
  -webkit-backdrop-filter: blur(var(--lg-blur, 20px)) saturate(180%);
  backdrop-filter: blur(var(--lg-blur, 20px)) saturate(180%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.5),
    inset 0 -1px 0 rgba(255, 255, 255, 0.1),
    inset 0 0 20px rgba(255, 255, 255, 0.08),
    0 8px 32px rgba(0, 0, 0, 0.18);
}
/* Borde de luz */
.lg-glass::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  padding: 1px;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.05) 40%, rgba(255, 255, 255, 0.05) 60%, rgba(255, 255, 255, 0.4));
  mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
/* Reflejo que sigue al cursor (--x / --y los pone track()) */
.lg-glass::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  pointer-events: none;
  opacity: var(--lg-hover, 0);
  transition: opacity 0.3s;
  background: radial-gradient(circle 140px at var(--x, 50%) var(--y, 0%), rgba(255, 255, 255, var(--lg-shine, 0.25)), transparent 70%);
}
.lg-glass:hover {
  --lg-hover: 1;
}
.lg-text {
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.lg-light .lg-text {
  color: #1d1d1f;
  text-shadow: none;
}
@keyframes lg-drift {
  0%, 100% { transform: translate(0, 0) scale(1); }
  33% { transform: translate(8%, -6%) scale(1.12); }
  66% { transform: translate(-6%, 7%) scale(0.94); }
}
`;

// Guarda la posición del cursor para el reflejo de .lg-glass::after
function track(e: MouseEvent<HTMLElement>) {
  const r = e.currentTarget.getBoundingClientRect();
  e.currentTarget.style.setProperty("--x", `${e.clientX - r.left}px`);
  e.currentTarget.style.setProperty("--y", `${e.clientY - r.top}px`);
}

interface StageProps {
  bg: Wallpaper;
  blur: number;
  opacity: number;
  shine: number;
  className?: string;
  children: ReactNode;
}

// Fondo de degradado animado sobre el que se ve el cristal
function Stage({
  bg,
  blur,
  opacity,
  shine,
  className = "relative h-[560px] w-full",
  children,
}: StageProps) {
  const light = bg === "light";
  const wall = walls[bg] ?? walls.aurora;
  const vars = {
    "--lg-blur": `${blur}px`,
    "--lg-a": light ? Math.min(0.6, opacity / 100 + 0.23) : opacity / 100,
    "--lg-shine": shine / 100,
  } as CSSProperties;

  return (
    <div
      className={`lg-font overflow-hidden rounded-[22px] ${className} ${light ? "lg-light" : ""}`}
      style={vars}
    >
      <style>{glassCss}</style>
      <div className="absolute inset-0 overflow-hidden" style={{ background: wall.base }}>
        <div className="absolute inset-0" style={{ filter: "blur(60px)" }}>
          {wall.blobs.map((color, i) => (
            <div
              key={i}
              className="absolute h-[75%] w-[75%] rounded-full"
              style={{
                left: blobPositions[i]?.[0],
                top: blobPositions[i]?.[1],
                background: `radial-gradient(circle, ${color} 0%, transparent 68%)`,
                opacity: light ? 1 : 0.9,
                animation: `lg-drift 20s ease-in-out ${-i * 6}s infinite`,
              }}
            />
          ))}
        </div>
        <div
          className="absolute inset-0"
          style={{ backgroundImage: grain, opacity: 0.04, mixBlendMode: "overlay" }}
        />
      </div>
      <div className="relative grid h-full w-full grid-cols-[100%] place-items-center p-4">{children}</div>
    </div>
  );
}

const items: { t: string; k: string; icon: LucideIcon; bg: string }[] = [
  { t: "Réglages", k: "App", icon: Settings, bg: "linear-gradient(#a1a1aa,#52525b)" },
  { t: "Calendrier", k: "App", icon: Calendar, bg: "linear-gradient(#ff7a6b,#f2493a)" },
  { t: "Navigateur", k: "App", icon: Compass, bg: "linear-gradient(#5ab8ff,#1a7cf5)" },
  { t: "Musique", k: "App", icon: Music2, bg: "linear-gradient(#ff6b93,#f3344f)" },
  { t: "Budget 2026.pdf", k: "Fichier", icon: FileText, bg: "linear-gradient(#ffb36b,#f97316)" },
  { t: "Projets", k: "Dossier", icon: Folder, bg: "linear-gradient(#7cc4ff,#3b82f6)" },
  { t: "Photos de voyage", k: "Fichier", icon: ImageIcon, bg: "linear-gradient(#ffd66b,#ff9f0a)" },
  { t: "Activer le mode sombre", k: "Action", icon: Moon, bg: "linear-gradient(#6b7280,#1f2937)" },
  { t: "Partager l’écran", k: "Action", icon: Airplay, bg: "linear-gradient(#5ef079,#27c24c)" },
];

interface SpotlightProps {
  label?: string;
  bg?: Wallpaper;
  blur?: number;
  opacity?: number;
  shine?: number;
}

export default function Spotlight({
  label = "Rechercher des apps, fichiers, actions…",
  bg = "aurora",
  blur = 20,
  opacity = 12,
  shine = 25,
}: SpotlightProps) {
  const glass = { bg, blur, opacity, shine };
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);

  const results = useMemo(() => {
    if (!query) return items.slice(0, 5);
    const q = query.toLowerCase();
    return items.filter((i) => i.t.toLowerCase().includes(q) || i.k.toLowerCase().includes(q));
  }, [query]);

  // ⌘K / Ctrl+K abre y cierra; Escape cierra
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setOpen((o) => !o);
      } else if (e.key === "Escape") {
        setOpen(false);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  useEffect(() => {
    setActive(0);
  }, [query]);

  const onKey = (e: ReactKeyboardEvent) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActive((x) => Math.min(results.length - 1, x + 1));
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      setActive((x) => Math.max(0, x - 1));
    }
    if (e.key === "Enter") setOpen(false);
  };

  return (
    <Stage {...glass}>
      <motion.div
        animate={{ filter: open ? "blur(6px)" : "blur(0px)", scale: open ? 0.97 : 1 }}
        transition={spring}
        className="flex flex-col items-center gap-3"
      >
        <motion.button
          type="button"
          onMouseMove={track}
          whileTap={{ scale: 0.95 }}
          transition={spring}
          onClick={() => setOpen(true)}
          className="lg-glass lg-text flex h-12 items-center gap-2.5 rounded-full px-5 text-[15px] font-medium"
        >
          <Search className="relative z-[3] h-4 w-4" strokeWidth={1.75} />
          <span className="relative z-[3]">Rechercher</span>
          <kbd className="relative z-[3] ml-2 rounded-md bg-white/20 px-1.5 text-[12px]">⌘K</kbd>
        </motion.button>
      </motion.div>

      <AnimatePresence>
        {open && (
          <>
            <motion.div
              className="absolute inset-0 z-10 bg-black/20"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={() => setOpen(false)}
            />
            <motion.div
              className="absolute inset-x-4 top-[14%] z-20 mx-auto max-w-[560px]"
              initial={{ opacity: 0, scale: 0.9 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.9 }}
              transition={spring}
            >
              <div onMouseMove={track} className="lg-glass lg-text rounded-[26px] p-2">
                <div className="relative z-[3] flex h-12 items-center gap-3 px-3">
                  <Search className="h-6 w-6 opacity-70" strokeWidth={1.75} />
                  <input
                    autoFocus
                    value={query}
                    onChange={(e) => setQuery(e.target.value)}
                    onKeyDown={onKey}
                    placeholder={label}
                    aria-label={label}
                    className="w-full bg-transparent text-[22px] font-light outline-none placeholder:text-current placeholder:opacity-50"
                  />
                </div>
                {results.length > 0 && (
                  <div className="relative z-[3] mt-1 border-t border-white/20 pt-2">
                    {results.map((r, i) => (
                      <motion.button
                        key={r.t}
                        type="button"
                        onMouseEnter={() => setActive(i)}
                        onClick={() => setOpen(false)}
                        initial={{ opacity: 0, y: 8 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ ...spring, delay: i * 0.035 }}
                        className="relative flex w-full items-center gap-3 rounded-[14px] px-3 py-2 text-left"
                      >
                        {active === i && (
                          <motion.span
                            layoutId="lg-spot"
                            transition={spring}
                            className="absolute inset-0 rounded-[14px]"
                            style={{ background: "rgba(255,255,255,.25)", boxShadow: "inset 0 1px 0 rgba(255,255,255,.5)" }}
                          />
                        )}
                        <span
                          className="relative grid h-8 w-8 place-items-center rounded-[9px]"
                          style={{ background: r.bg }}
                        >
                          <r.icon className="h-[18px] w-[18px] text-white" strokeWidth={1.75} />
                        </span>
                        <span className="relative flex-1 text-[15px]">{r.t}</span>
                        <span className="relative text-[12px] opacity-60">{r.k}</span>
                      </motion.button>
                    ))}
                  </div>
                )}
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </Stage>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Version Markdown avec tout le code, pour les assistants IA

Plus dans Liquid Glass

Voir tout : Liquid Glass

Où l’utiliser

Une palette de commandes est le moyen le plus rapide de circuler dans un outil riche en pages : un back-office, la documentation d’une API ou une appli de gestion de projet. Celle-ci s’ouvre avec ⌘K ou Ctrl+K, filtre applis, fichiers et actions pendant la saisie et floute la page derrière.

Gardez une liste de départ courte, cinq à huit résultats, avec les éléments récents ou populaires en tête. Les étiquettes à droite, comme App, Fichier ou Action, aident à parcourir la liste. Échap la ferme, personne ne se sent coincé.