9:41
React + MotionLiquid Glass
Notifications d’écran verrouillé
Une horloge en temps réel au-dessus d’une pile de notifications qui se déplie et se balaie pour effacer.
Réglages
20px
12%
25%
Code
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: "Messages", color: "linear-gradient(#5ef079,#27c24c)", icon: MessageCircle, title: "Lucía", text: "On se retrouve à 8 h en terrasse ?", time: "ahora" },
{ app: "Mail", color: "linear-gradient(#5ab8ff,#1a7cf5)", icon: Mail, title: "Résumé hebdomadaire", text: "Vos 3 projets ont avancé cette semaine.", time: "5 min" },
{ app: "Calendrier", color: "linear-gradient(#ff7a6b,#f2493a)", icon: Calendar, title: "Design · 10:30", text: "Revue du prototype avec l’équipe.", time: "20 min" },
{ app: "Musique", color: "linear-gradient(#ff6b93,#f3344f)", icon: Music2, title: "Nouvelle version", text: "L’album que vous avez enregistré est disponible.", time: "1 h" },
{ app: "Photos", color: "linear-gradient(#ffd66b,#ff9f0a)", icon: ImageIcon, title: "Souvenirs", text: "Il y a un an, jour pour jour.", 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 "Effacer"; 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"
>
Effacer
</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 = "Recevoir une notification",
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]">Afficher moins</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 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
Si votre produit envoie des notifications, cet écran verrouillé les met en valeur sur une page d’accueil : les rappels d’une appli de calendrier, les messages d’une messagerie ou le suivi de commande d’un service de livraison. La pile de notifications façon iOS les regroupe et un toucher les déplie.
Remplacez les messages d’exemple par de vrais textes de votre produit, rédigés comme votre appli les enverrait. Cela convainc plus qu’un grand titre. Le glissement pour supprimer marche aussi à la souris, pratique pour une démo.