L

Laura García

llamada entrante…

Rechazar
Aceptar
React + MotionLiquid Glass

Eingehender Anruf

Ein pulsierender Avatar, Wischen zum Annehmen und ein kompletter Anrufbildschirm mit Timer, Stummschalten, Tastatur und Lautsprecher.

Einstellungen

20px
12%
25%

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="Ablehnen"
                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="Ablehnen"
                  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]">Ablehnen</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="Annehmen"
                  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]">Annehmen</span>
              </div>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>
    </Stage>
  );
}

Prompt für KI

Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.

Mehr aus Liquid Glass

Wofür du ihn nutzen kannst

Dieser Screen für eingehende Anrufe deckt den ganzen Ablauf ab, vom Klingeln über das Gespräch bis zum Auflegen. Er passt zu einer Telemedizin-App, in der die Ärztin anruft, zu einem Support-Tool mit Sprachanrufen oder zum Prototyp einer Dating-App. Angenommen wird per Tipp oder Wischen.

Ersetz den Buchstaben im Avatar so bald wie möglich durch ein echtes Foto, denn ein Gesicht macht den Anruf persönlich. Der grüne Button wackelt, um aufzufallen, also halte den Rest des Bildschirms ruhig, solange es klingelt.