9:41
React + MotionLiquid Glass
Notificaciones de bloqueo
Un reloj en tiempo real sobre una pila de avisos que se despliega al tocarla y se desliza para borrar.
Ajustes
20px
12%
25%
Código
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useMotionValue, useTransform } from "framer-motion";
import { Bell, Calendar, Image as ImageIcon, Mail, MessageCircle, Music2, 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 Notif = {
id: number;
app: string;
color: string;
icon: LucideIcon;
title: string;
text: string;
time: string;
};
const pool: Omit<Notif, "id">[] = [
{ app: "Mensajes", color: "linear-gradient(#5ef079,#27c24c)", icon: MessageCircle, title: "Lucía", text: "¿Quedamos a las 8 en la terraza?", time: "ahora" },
{ app: "Correo", color: "linear-gradient(#5ab8ff,#1a7cf5)", icon: Mail, title: "Resumen semanal", text: "Tus 3 proyectos avanzaron esta semana.", time: "5 min" },
{ app: "Calendario", color: "linear-gradient(#ff7a6b,#f2493a)", icon: Calendar, title: "Diseño · 10:30", text: "Revisión del prototipo con el equipo.", time: "20 min" },
{ app: "Música", color: "linear-gradient(#ff6b93,#f3344f)", icon: Music2, title: "Nuevo lanzamiento", text: "Ya está disponible el álbum que guardaste.", time: "1 h" },
{ app: "Fotos", color: "linear-gradient(#ffd66b,#ff9f0a)", icon: ImageIcon, title: "Recuerdos", text: "Un día como hoy, hace un año.", time: "ahora" },
];
// La hora se fija tras montar para no desajustar el render del servidor
function useClock() {
const [date, setDate] = useState<Date | null>(null);
useEffect(() => {
setDate(new Date());
const t = setInterval(() => setDate(new Date()), 1000);
return () => clearInterval(t);
}, []);
return date;
}
// Desliza a la izquierda para mostrar "Borrar"; si arrastras lejos, se borra sola
function NotifRow({ n, onDelete }: { n: Notif; onDelete: () => void }) {
const x = useMotionValue(0);
const deleteOpacity = useTransform(x, [-90, -30], [1, 0]);
const Icon = n.icon;
return (
<div className="relative">
<motion.button
type="button"
style={{ opacity: deleteOpacity }}
onClick={onDelete}
className="absolute inset-y-0 right-0 grid w-[84px] place-items-center rounded-[22px] bg-[#ff3b30] text-[14px] font-semibold text-white"
>
Borrar
</motion.button>
<motion.div
drag="x"
dragConstraints={{ left: -96, right: 0 }}
dragElastic={0.1}
style={{ x }}
onDragEnd={(_, info) => {
if (info.offset.x < -150) onDelete();
else x.set(info.offset.x < -50 ? -96 : 0);
}}
onMouseMove={track}
className="lg-glass lg-text flex items-center gap-3 rounded-[22px] p-3"
>
<div
className="relative z-[3] grid h-[38px] w-[38px] shrink-0 place-items-center rounded-[10px]"
style={{ background: n.color }}
>
<Icon className="h-5 w-5 text-white" strokeWidth={1.75} />
</div>
<div className="relative z-[3] min-w-0 flex-1">
<div className="flex justify-between gap-2">
<p className="truncate text-[14px] font-semibold">{n.title}</p>
<span className="shrink-0 text-[12px] opacity-60">{n.time}</span>
</div>
<p className="truncate text-[13px] opacity-80">{n.text}</p>
</div>
</motion.div>
</div>
);
}
interface LockNotificationsProps {
label?: string;
bg?: Wallpaper;
blur?: number;
opacity?: number;
shine?: number;
}
export default function LockNotifications({
label = "Recibir notificación",
bg = "aurora",
blur = 20,
opacity = 12,
shine = 25,
}: LockNotificationsProps) {
const glass = { bg, blur, opacity, shine };
const now = useClock();
const nextId = useRef(10);
const [list, setList] = useState<Notif[]>(() => pool.slice(0, 4).map((p, i) => ({ ...p, id: i })));
const [open, setOpen] = useState(false);
const time = now ? now.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" }) : "9:41";
const date = now ? now.toLocaleDateString("es-ES", { weekday: "long", day: "numeric", month: "long" }) : "";
const add = () => {
const p = pool[nextId.current % pool.length];
if (!p) return;
setList((l) => [{ ...p, time: "ahora", id: nextId.current++ }, ...l]);
};
return (
<Stage {...glass}>
<div className="absolute inset-0 flex flex-col items-center overflow-hidden px-4 pt-8">
<p className="lg-text text-[17px] font-medium capitalize">{date}</p>
<p className="lg-text text-[90px] font-extralight leading-none tracking-[-0.04em] tabular-nums">{time}</p>
<div className="mt-6 w-full max-w-[360px]">
{open ? (
<motion.div layout className="flex flex-col gap-2">
<AnimatePresence initial={false}>
{list.map((n) => (
<motion.div
key={n.id}
layout
initial={{ opacity: 0, y: -40, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: -300, scale: 0.9 }}
transition={spring}
>
<NotifRow n={n} onDelete={() => setList((l) => l.filter((q) => q.id !== n.id))} />
</motion.div>
))}
</AnimatePresence>
{list.length > 0 && (
<motion.button
type="button"
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setOpen(false)}
className="lg-glass lg-text mx-auto mt-1 h-8 rounded-full px-4 text-[13px] font-medium"
>
<span className="relative z-[3]">Mostrar menos</span>
</motion.button>
)}
</motion.div>
) : (
// Pila cerrada: hasta 3 tarjetas, solo la primera con contenido
<button
type="button"
onClick={() => setOpen(true)}
className="relative block w-full"
style={{ height: list.length ? 76 + Math.min(list.length - 1, 2) * 10 : 0 }}
>
<AnimatePresence initial={false}>
{list.slice(0, 3).map((n, i) => (
<motion.div
key={n.id}
className="absolute inset-x-0 top-0"
style={{ zIndex: 10 - i }}
initial={{ y: -80, opacity: 0, scale: 1 }}
animate={{ y: i * 10, scale: 1 - i * 0.05, opacity: i === 0 ? 1 : 0.75 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={spring}
>
<div
className="lg-glass lg-text flex items-center gap-3 rounded-[22px] p-3 text-left"
style={i ? { minHeight: 64 } : {}}
>
{i === 0 && (
<>
<div
className="relative z-[3] grid h-[38px] w-[38px] shrink-0 place-items-center rounded-[10px]"
style={{ background: n.color }}
>
<n.icon className="h-5 w-5 text-white" strokeWidth={1.75} />
</div>
<div className="relative z-[3] min-w-0 flex-1">
<div className="flex justify-between gap-2">
<p className="truncate text-[14px] font-semibold">{n.title}</p>
<span className="text-[12px] opacity-60">{n.time}</span>
</div>
<p className="truncate text-[13px] opacity-80">
{n.text}
{list.length > 1 ? ` · +${list.length - 1} más` : ""}
</p>
</div>
</>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</button>
)}
</div>
<motion.button
type="button"
onMouseMove={track}
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={add}
className="lg-glass lg-text mb-6 mt-auto flex h-11 shrink-0 items-center gap-2 rounded-full px-5 text-[15px] font-medium"
>
<Bell className="relative z-[3] h-4 w-4" strokeWidth={1.75} />
<span className="relative z-[3]">{label}</span>
</motion.button>
</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
Ver todos: Liquid GlassDónde usarlo
Si tu producto manda notificaciones, esta pantalla de bloqueo es una forma muy clara de enseñarlas en una landing: los recordatorios de una app de calendario, los mensajes de una app de chat o el estado de los pedidos de un reparto. La pila de notificaciones estilo iOS las agrupa y un toque las despliega.
Cambia los mensajes de ejemplo por otros reales de tu producto, escritos tal y como los enviaría tu app. Eso vende más que cualquier titular. Deslizar para borrar también funciona con el ratón, así que queda bien en demos.