React + MotionLiquid Glass

Widgets de inicio

Widgets de cristal de tiempo, calendario, batería y pasos que se amplían al doble de ancho al tocarlos.

Ajustes

20px
12%
25%

Código

import { useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { motion } from "framer-motion";
import { Cloud, Music2, Phone, Sun, Zap, 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>
  );
}

function Ring({ p, r, color, sw = 5 }: { p: number; r: number; color: string; sw?: number }) {
  const c = 2 * Math.PI * r;
  return (
    <svg width={r * 2 + sw * 2} height={r * 2 + sw * 2} className="-rotate-90">
      <circle cx={r + sw} cy={r + sw} r={r} fill="none" stroke="currentColor" strokeOpacity={0.2} strokeWidth={sw} />
      <motion.circle
        cx={r + sw}
        cy={r + sw}
        r={r}
        fill="none"
        stroke={color}
        strokeWidth={sw}
        strokeLinecap="round"
        strokeDasharray={c}
        initial={{ strokeDashoffset: c }}
        animate={{ strokeDashoffset: c * (1 - p) }}
        transition={{ ...spring, stiffness: 60, damping: 18 }}
      />
    </svg>
  );
}

const hours = ["12", "13", "14", "15", "16"];

const events = [
  { color: "#ff9f0a", title: "Diseño", time: "10:30" },
  { color: "#30d158", title: "Almuerzo", time: "14:00" },
  { color: "#0a84ff", title: "Yoga", time: "19:00" },
];

const devices: { p: number; name: string; icon: LucideIcon }[] = [
  { p: 0.82, name: "Móvil", icon: Phone },
  { p: 0.54, name: "Reloj", icon: Zap },
  { p: 0.31, name: "Auriculares", icon: Music2 },
];

interface WidgetsProps {
  label?: string;
  bg?: Wallpaper;
  blur?: number;
  opacity?: number;
  shine?: number;
  tint?: boolean;
}

export default function Widgets({
  label = "Madrid",
  bg = "aurora",
  blur = 20,
  opacity = 12,
  shine = 25,
  tint = false,
}: WidgetsProps) {
  const glass = { bg, blur, opacity, shine };
  const [big, setBig] = useState<string | null>(null);

  // Pulsar un widget lo agranda a dos columnas; pulsarlo de nuevo lo devuelve
  const W = ({ id, children }: { id: string; children: ReactNode }) => (
    <motion.button
      layout
      type="button"
      aria-expanded={big === id}
      onClick={() => setBig(big === id ? null : id)}
      onMouseMove={track}
      whileTap={{ scale: 0.97 }}
      transition={spring}
      className="lg-glass lg-text h-[160px] rounded-[24px] p-4 text-left"
      style={{
        gridColumn: big === id ? "span 2" : "span 1",
        ...(tint ? { background: "rgba(59,130,246,.35)" } : {}),
      }}
    >
      <motion.div layout="position" className="relative z-[3] h-full">
        {children}
      </motion.div>
    </motion.button>
  );

  return (
    <Stage {...glass}>
      <motion.div layout className="grid w-[344px] max-w-[calc(100vw-32px)] grid-cols-2 gap-4" transition={spring}>
        <W id="w">
          <div className="flex h-full flex-col">
            <div className="flex items-start justify-between">
              <div>
                <p className="text-[14px] font-semibold">{label}</p>
                <p className="text-[40px] font-light leading-none">22°</p>
              </div>
              <div className="relative h-9 w-10">
                <motion.div
                  animate={{ rotate: 360 }}
                  transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
                  className="absolute right-0 top-0"
                >
                  <Sun className="h-7 w-7 text-[#ffd60a]" strokeWidth={1.75} fill="#ffd60a" />
                </motion.div>
                <motion.div
                  animate={{ x: [-4, 4, -4] }}
                  transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
                  className="absolute bottom-0 left-0"
                >
                  <Cloud className="h-7 w-7 text-white" fill="white" strokeWidth={1.75} />
                </motion.div>
              </div>
            </div>
            <div className="mt-auto flex justify-between text-center text-[11px]">
              {hours.map((h, i) => (
                <div key={h}>
                  <p className="opacity-60">{h}</p>
                  {i % 2 ? (
                    <Cloud className="mx-auto my-1 h-4 w-4" strokeWidth={1.75} />
                  ) : (
                    <Sun className="mx-auto my-1 h-4 w-4" strokeWidth={1.75} />
                  )}
                  <p className="font-medium">{22 - i}°</p>
                </div>
              ))}
            </div>
          </div>
        </W>

        <W id="c">
          <div className="flex h-full flex-col">
            <p className="text-[11px] font-semibold uppercase text-[#ff453a]">viernes</p>
            <p className="text-[34px] font-light leading-none">25</p>
            <div className="mt-2 flex flex-col gap-1.5">
              {events.map((e) => (
                <div key={e.title} className="flex items-center gap-2">
                  <span className="h-6 w-[3px] rounded-full" style={{ background: e.color }} />
                  <div className="leading-tight">
                    <p className="text-[12px] font-semibold">{e.title}</p>
                    <p className="text-[10px] opacity-60">{e.time}</p>
                  </div>
                </div>
              ))}
            </div>
          </div>
        </W>

        <W id="b">
          <div className="flex h-full flex-col">
            <p className="text-[14px] font-semibold">Batería</p>
            <div className="mt-auto flex justify-around">
              {devices.map(({ p, name, icon: Icon }) => (
                <div key={name} className="flex flex-col items-center gap-1">
                  <div className="relative">
                    <Ring p={p} r={17} sw={4} color={p < 0.35 ? "#ffd60a" : "#30d158"} />
                    <Icon
                      className="absolute left-1/2 top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2"
                      strokeWidth={1.75}
                    />
                  </div>
                  <span className="text-[12px] font-medium">{Math.round(p * 100)}%</span>
                </div>
              ))}
            </div>
          </div>
        </W>

        <W id="s">
          <div className="flex h-full flex-col justify-between">
            <p className="text-[14px] font-semibold">Pasos</p>
            <p className="text-[34px] font-light leading-none">8.412</p>
            <div className="h-2 overflow-hidden rounded-full bg-white/25">
              <motion.div
                className="h-full rounded-full bg-[#30d158]"
                initial={{ width: 0 }}
                animate={{ width: "84%" }}
                transition={{ ...spring, stiffness: 60 }}
              />
            </div>
          </div>
        </W>
      </motion.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.

Más en Liquid Glass

Dónde usarlo

Estos widgets estilo iOS montan una pantalla de resumen muy amable: el inicio de una app de deporte, un panel personal con la agenda del día o el control de una casa domótica con la batería de cada aparato. Cada bloque cuenta una sola cosa y un toque lo ensancha para dar más detalle.

Con un número grande por widget basta. Si tu fondo es muy cargado o muy claro, activa el modo tintado para que el cristal coja algo de color y el texto se lea bien. Cambia la ciudad por la de tus usuarios.