9:41
React + MotionLiquid Glass
Lock screen notifications
A live clock over a stack of notifications that fans out on tap and lets you swipe each one away.
Settings
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: "Meet at 8 on the terrace?", time: "ahora" },
{ app: "Mail", color: "linear-gradient(#5ab8ff,#1a7cf5)", icon: Mail, title: "Weekly summary", text: "All 3 of your projects moved forward this week.", time: "5 min" },
{ app: "Calendar", color: "linear-gradient(#ff7a6b,#f2493a)", icon: Calendar, title: "Design · 10:30 AM", text: "Prototype review with the team.", time: "20 min" },
{ app: "Music", color: "linear-gradient(#ff6b93,#f3344f)", icon: Music2, title: "New release", text: "The album you saved is now available.", time: "1 h" },
{ app: "Photos", color: "linear-gradient(#ffd66b,#ff9f0a)", icon: ImageIcon, title: "Memories", text: "On this day, one year ago.", 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 "Clear"; 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"
>
Clear
</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 = "Get a 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]">Show less</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 for AI
Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.
More in Liquid Glass
Where to use it
If your product sends notifications, this lock screen is a great way to show them off on a landing page: the reminders of a calendar app, the messages of a chat app or the order updates of a delivery service. The iOS notification stack groups them, and one tap spreads them out.
Replace the sample messages with real ones from your product, written the way your app would really send them. That does more for the pitch than any headline. Swiping to delete also works with a mouse, which makes it nice for demos.