React + MotionLiquid Glass

Dock de escritorio

Un dock de cristal estilo macOS: los iconos crecen cerca del cursor, rebotan al pulsar y enseñan su nombre.

Ajustes

20px
12%
25%

Código

import { useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useMotionValue, useSpring, useTransform, type MotionValue } from "framer-motion";
import { Calendar, Compass, Folder, Mail, MessageCircle, Music2, Settings, Sparkles, 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>
  );
}

type App = { n: string; bg: string; icon: LucideIcon };

const apps: App[] = [
  { n: "Finder", bg: "linear-gradient(#7cc4ff,#1a7cf5)", icon: Folder },
  { n: "Navegador", bg: "linear-gradient(#e0f2fe,#38bdf8)", icon: Compass },
  { n: "Mensajes", bg: "linear-gradient(#5ef079,#27c24c)", icon: MessageCircle },
  { n: "Correo", bg: "linear-gradient(#5ab8ff,#1a56f5)", icon: Mail },
  { n: "Fotos", bg: "conic-gradient(#f43f5e,#f97316,#facc15,#22c55e,#3b82f6,#8b5cf6,#f43f5e)", icon: Sparkles },
  { n: "Música", bg: "linear-gradient(#ff6b93,#f3344f)", icon: Music2 },
  { n: "Calendario", bg: "linear-gradient(#fff,#e5e5ea)", icon: Calendar },
  { n: "Ajustes", bg: "linear-gradient(#a1a1aa,#52525b)", icon: Settings },
];

// Cada icono crece según la distancia horizontal del cursor a su centro
function DockIcon({ mx, app, i }: { mx: MotionValue<number>; app: App; i: number }) {
  const ref = useRef<HTMLButtonElement>(null);
  const [hover, setHover] = useState(false);
  const [open, setOpen] = useState(i < 2);
  const [bounce, setBounce] = useState(0);
  const dist = useTransform(mx, (x) => {
    const r = ref.current?.getBoundingClientRect();
    return r ? x - r.left - r.width / 2 : 999;
  });
  const size = useSpring(useTransform(dist, [-150, 0, 150], [52, 83, 52]), { stiffness: 400, damping: 30 });
  const isCalendar = app.n === "Calendario";

  return (
    <div className="relative flex flex-col items-center">
      <AnimatePresence>
        {hover && (
          <motion.div
            initial={{ opacity: 0, y: 6, scale: 0.9 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 6, scale: 0.9 }}
            transition={spring}
            className="lg-glass lg-text absolute -top-11 whitespace-nowrap rounded-[10px] px-2.5 py-1 text-[13px] font-medium"
          >
            <span className="relative z-[3]">{app.n}</span>
          </motion.div>
        )}
      </AnimatePresence>
      <motion.button
        ref={ref}
        type="button"
        aria-label={app.n}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
        onClick={() => {
          setBounce((b) => b + 1);
          setOpen(true);
        }}
        style={{ width: size, height: size }}
        // La key cambia con cada clic para repetir el rebote
        key={bounce}
        animate={bounce ? { y: [0, -24, 0, -10, 0] } : {}}
        transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
        className="grid place-items-center rounded-[23%]"
      >
        <motion.span
          className="grid h-full w-full place-items-center rounded-[23%]"
          style={{ background: app.bg, boxShadow: "inset 0 1px 0 rgba(255,255,255,.5), 0 4px 12px rgba(0,0,0,.2)" }}
        >
          {isCalendar ? (
            <span className="text-center leading-none text-[#1d1d1f]">
              <span className="block text-[9px] font-semibold text-[#ff3b30]">VIE</span>
              <span className="text-[22px] font-light">25</span>
            </span>
          ) : (
            <app.icon className="h-1/2 w-1/2 text-white" strokeWidth={1.75} />
          )}
        </motion.span>
      </motion.button>
      <span
        className="mt-1 h-1 w-1 rounded-full transition-opacity"
        style={{ background: "currentColor", opacity: open ? 0.8 : 0 }}
      />
    </div>
  );
}

interface DockProps {
  bg?: Wallpaper;
  blur?: number;
  opacity?: number;
  shine?: number;
}

export default function Dock({
  bg = "aurora",
  blur = 20,
  opacity = 12,
  shine = 25,
}: DockProps) {
  const glass = { bg, blur, opacity, shine };
  const mx = useMotionValue(-9999);

  return (
    <Stage {...glass}>
      <div className="absolute inset-x-0 bottom-6 flex justify-center">
        <div
          onMouseMove={(e) => {
            track(e);
            mx.set(e.clientX);
          }}
          onMouseLeave={() => mx.set(-9999)}
          className="lg-glass lg-text flex h-[76px] items-end gap-2 rounded-[24px] px-3 pb-1.5"
          style={{ overflow: "visible" }}
        >
          {apps.map((app, i) => (
            <div key={app.n} className="relative z-[3]">
              <DockIcon mx={mx} app={app} i={i} />
            </div>
          ))}
        </div>
      </div>
    </Stage>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Versión Markdown con todo el código, para asistentes de IA

Más en Liquid Glass

Ver todos: Liquid Glass

Dónde usarlo

El efecto dock de macOS es un clásico para portfolios montados como un pequeño escritorio, donde cada icono abre un proyecto, la página sobre ti o tus datos de contacto. También sirve de lanzador en un panel interno o como fila divertida de redes sociales al pie de una web personal.

Usa iconos que la gente reconozca y no pases de diez, o la ampliación se convierte en una ola a la que nadie atina. En pantallas táctiles no hay hover, así que en el móvil se queda como una fila ordenada de iconos.