Laura García
llamada entrante…
Écran d’appel entrant
Un avatar qui pulse, un bouton à glisser pour répondre et un écran d’appel avec durée, micro coupé, clavier et haut-parleur.
Réglages
Code
import { useEffect, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useMotionValue } from "framer-motion";
import { Grid3x3, Mic, MicOff, Phone, PhoneOff, Volume2, 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>
);
}
interface IncomingCallProps {
label?: string;
bg?: Wallpaper;
blur?: number;
opacity?: number;
shine?: number;
}
export default function IncomingCall({
label = "Laura García",
bg = "aurora",
blur = 20,
opacity = 12,
shine = 25,
}: IncomingCallProps) {
const glass = { bg, blur, opacity, shine };
const [state, setState] = useState<"ring" | "active" | "ended">("ring");
const [sec, setSec] = useState(0);
const [mute, setMute] = useState(false);
const [speaker, setSpeaker] = useState(false);
const [pad, setPad] = useState(false);
const x = useMotionValue(0);
useEffect(() => {
if (state !== "active") return;
setSec(0);
const t = setInterval(() => setSec((s) => s + 1), 1000);
return () => clearInterval(t);
}, [state]);
// Tras colgar, la demo vuelve a sonar
useEffect(() => {
if (state !== "ended") return;
const t = setTimeout(() => setState("ring"), 1600);
return () => clearTimeout(t);
}, [state]);
const controls: { icon: LucideIcon; text: string; on: boolean; toggle: () => void }[] = [
{ icon: mute ? MicOff : Mic, text: "silencio", on: mute, toggle: () => setMute((m) => !m) },
{ icon: Grid3x3, text: "teclado", on: pad, toggle: () => setPad((p) => !p) },
{ icon: Volume2, text: "altavoz", on: speaker, toggle: () => setSpeaker((s) => !s) },
];
return (
<Stage {...glass}>
<div className="absolute inset-0 backdrop-blur-2xl" style={{ background: "rgba(0,0,0,.15)" }} />
<div className="lg-text absolute inset-0 flex flex-col items-center pt-14">
<div className="relative grid place-items-center">
{state === "ring" &&
[0, 1].map((k) => (
<motion.span
key={k}
className="absolute h-[120px] w-[120px] rounded-full border border-white/50"
animate={{ scale: [1, 1.5], opacity: [0.6, 0] }}
transition={{ duration: 2, delay: k, repeat: Infinity, ease: "easeOut" }}
/>
))}
<motion.div
onMouseMove={track}
animate={state === "ring" ? { scale: [1, 1.04, 1] } : { scale: 0.8 }}
transition={state === "ring" ? { duration: 2, repeat: Infinity, ease: "easeInOut" } : spring}
className="lg-glass grid h-[120px] w-[120px] place-items-center rounded-full p-1.5"
>
<div className="relative z-[3] grid h-full w-full place-items-center rounded-full bg-gradient-to-b from-[#a1a1aa] to-[#52525b] text-[46px] font-light text-white">
{label.charAt(0)}
</div>
</motion.div>
</div>
<p className="mt-6 text-[34px] font-normal tracking-[-0.02em]">{label}</p>
<AnimatePresence mode="wait">
<motion.p
key={state}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 0.7, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={spring}
className="text-[17px] tabular-nums"
>
{state === "ring"
? "llamada entrante…"
: state === "ended"
? "llamada finalizada"
: `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`}
</motion.p>
</AnimatePresence>
<AnimatePresence mode="wait">
{state === "active" ? (
<motion.div
key="active"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={spring}
className="absolute bottom-8 flex flex-col items-center gap-6"
>
<AnimatePresence>
{pad && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={spring}
className="grid grid-cols-3 gap-2"
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((k) => (
<motion.button
key={k}
type="button"
whileTap={{ scale: 0.95 }}
transition={spring}
onMouseMove={track}
className="lg-glass lg-text h-11 w-11 rounded-full text-[18px]"
>
<span className="relative z-[3]">{k}</span>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
<div className="flex gap-6">
{controls.map(({ icon: Icon, text, on, toggle }) => (
<div key={text} className="flex flex-col items-center gap-1.5">
<motion.button
type="button"
aria-label={text}
aria-pressed={on}
onMouseMove={track}
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={toggle}
className="lg-glass lg-text grid h-[64px] w-[64px] place-items-center rounded-full"
style={on ? { background: "#fff" } : {}}
>
<Icon
className="relative z-[3] h-6 w-6"
strokeWidth={1.75}
style={on ? { color: "#1d1d1f" } : {}}
/>
</motion.button>
<span className="text-[12px]">{text}</span>
</div>
))}
</div>
<motion.button
type="button"
aria-label="Refuser"
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setState("ended")}
className="grid h-[64px] w-[64px] place-items-center rounded-full bg-[#ff3b30] text-white shadow-lg"
>
<PhoneOff className="h-6 w-6" strokeWidth={1.75} />
</motion.button>
</motion.div>
) : state === "ring" ? (
<motion.div
key="ring"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={spring}
className="absolute inset-x-0 bottom-10 flex justify-between px-12"
>
<div className="flex flex-col items-center gap-2">
<motion.button
type="button"
aria-label="Refuser"
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setState("ended")}
className="lg-glass grid h-[72px] w-[72px] place-items-center rounded-full"
style={{ background: "rgba(255,59,48,.85)" }}
>
<PhoneOff className="relative z-[3] h-7 w-7 text-white" strokeWidth={1.75} />
</motion.button>
<span className="text-[13px]">Refuser</span>
</div>
<div className="flex flex-col items-center gap-2">
{/* Pulsa o arrastra a la izquierda para contestar */}
<motion.button
type="button"
aria-label="Accepter"
drag="x"
dragConstraints={{ left: -80, right: 0 }}
dragElastic={0.2}
dragSnapToOrigin
onDragEnd={(_, info) => {
if (info.offset.x < -50) setState("active");
}}
onClick={() => setState("active")}
animate={{ rotate: [0, -8, 8, -6, 6, 0, 0, 0, 0] }}
transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}
whileTap={{ scale: 0.95 }}
className="lg-glass grid h-[72px] w-[72px] place-items-center rounded-full"
style={{ x, background: "rgba(52,199,89,.9)" }}
>
<Phone className="relative z-[3] h-7 w-7 text-white" strokeWidth={1.75} fill="white" />
</motion.button>
<span className="text-[13px]">Accepter</span>
</div>
</motion.div>
) : null}
</AnimatePresence>
</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
Cet écran d’appel entrant couvre tout le parcours, de la sonnerie à la conversation puis au raccrochage. Il convient à une appli de téléconsultation où le médecin appelle, à un outil de support client avec appels vocaux ou au prototype d’une appli de rencontres. On répond d’un toucher ou en glissant.
Remplacez au plus vite l’initiale de l’avatar par une vraie photo : un visage rend l’appel plus humain. Le bouton vert se trémousse pour attirer l’œil, gardez donc le reste de l’écran calme pendant la sonnerie.