Voyage vers le nord

Una experiencia fluida, pensada para que todo se sienta ligero.

React + MotionLiquid Glass

Carte en verre

Une carte en verre dépoli qui s’incline doucement en 3D vers le curseur, suivie d’un reflet léger.

Réglages

20px
12%
25%

Code

import { type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { Heart, Sparkles } 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>
  );
}

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

export default function GlassCard({
  label = "Voyage vers le nord",
  bg = "aurora",
  blur = 20,
  opacity = 12,
  shine = 25,
}: GlassCardProps) {
  const glass = { bg, blur, opacity, shine };
  const px = useMotionValue(0.5);
  const py = useMotionValue(0.5);
  const rotateX = useSpring(useTransform(py, [0, 1], [8, -8]), spring);
  const rotateY = useSpring(useTransform(px, [0, 1], [-8, 8]), spring);

  const onMove = (e: MouseEvent<HTMLDivElement>) => {
    track(e);
    const r = e.currentTarget.getBoundingClientRect();
    px.set((e.clientX - r.left) / r.width);
    py.set((e.clientY - r.top) / r.height);
  };

  return (
    <Stage {...glass}>
      <div style={{ perspective: 1000 }}>
        <motion.div
          onMouseMove={onMove}
          onMouseLeave={() => {
            px.set(0.5);
            py.set(0.5);
          }}
          style={{ rotateX, rotateY, width: 320, height: 420, borderRadius: 32 }}
          className="lg-glass lg-text flex max-w-[calc(100vw-48px)] flex-col p-7"
        >
          <div
            className="relative z-[3] grid h-16 w-16 place-items-center rounded-[20px]"
            style={{ background: "rgba(255,255,255,.18)", boxShadow: "inset 0 1px 0 rgba(255,255,255,.5)" }}
          >
            <Sparkles className="h-8 w-8" strokeWidth={1.5} />
          </div>
          <div className="relative z-[3] mt-auto">
            <h3 className="text-[28px] font-bold leading-tight tracking-[-0.02em]">{label}</h3>
            <p className="mt-1.5 text-[15px] opacity-70">
              Une expérience fluide, pensée pour que tout paraisse léger.
            </p>
            <motion.button
              type="button"
              onMouseMove={track}
              whileTap={{ scale: 0.95 }}
              transition={spring}
              className="lg-glass mt-6 h-12 w-full rounded-full text-[16px] font-semibold"
            >
              <span className="relative z-[3] inline-flex items-center gap-2">
                <Heart className="h-4 w-4" strokeWidth={1.75} />
                Découvrir
              </span>
            </motion.button>
          </div>
        </motion.div>
      </div>
    </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.

Plus dans Liquid Glass

Où l’utiliser

Cette carte glassmorphism convient aux contenus sur lesquels vous voulez qu’on s’attarde : une destination sur le site d’une agence de voyages, l’offre mise en avant d’une page de tarifs ou la couverture d’un projet dans un portfolio. L’effet de carte inclinable en 3D reste discret, quelques degrés à peine.

Une ou deux par écran, pas plus. Une grille de douze cartes qui bougent en même temps fatigue vite. Sur mobile, sans survol, la carte reste immobile : le texte qu’elle porte doit donc se suffire à lui-même.