React + MotionLiquid Glass

Dynamic Island

Eine schwarze Pille, die sich federnd in einen Anruf, einen Musikplayer oder einen laufenden Timer verwandelt.

Einstellungen

20px
12%
25%

Code

import { useEffect, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Phone, PhoneOff, Timer } 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 Mode = "idle" | "call" | "music" | "timer";

const sizes: Record<Mode, { width: number; height: number; borderRadius: number }> = {
  idle: { width: 126, height: 37, borderRadius: 19 },
  call: { width: 350, height: 80, borderRadius: 40 },
  music: { width: 230, height: 37, borderRadius: 19 },
  timer: { width: 200, height: 37, borderRadius: 19 },
};

const labels: Record<Mode, string> = {
  idle: "Ruhemodus",
  call: "Anruf",
  music: "Musik",
  timer: "Timer",
};

function Bars() {
  return (
    <div className="flex h-4 items-center gap-[3px]">
      {[0, 1, 2, 3].map((i) => (
        <motion.span
          key={i}
          className="w-[3px] rounded-full bg-[#ff6482]"
          animate={{ height: [5, 15, 7, 12, 5] }}
          transition={{ duration: 0.9 + i * 0.15, repeat: Infinity, ease: "easeInOut" }}
        />
      ))}
    </div>
  );
}

interface DynamicIslandProps {
  label?: string;
  bg?: Wallpaper;
  blur?: number;
  opacity?: number;
  shine?: number;
}

export default function DynamicIsland({
  label = "Laura García",
  bg = "aurora",
  blur = 20,
  opacity = 12,
  shine = 25,
}: DynamicIslandProps) {
  const glass = { bg, blur, opacity, shine };
  const [mode, setMode] = useState<Mode>("idle");
  const [sec, setSec] = useState(272);

  useEffect(() => {
    if (mode !== "timer") return;
    const t = setInterval(() => setSec((s) => (s > 0 ? s - 1 : 272)), 1000);
    return () => clearInterval(t);
  }, [mode]);

  return (
    <Stage {...glass}>
      <div className="absolute inset-x-0 top-5 flex justify-center">
        <motion.div
          animate={sizes[mode]}
          transition={spring}
          className="overflow-hidden bg-black text-white"
          style={{ maxWidth: "calc(100% - 24px)", boxShadow: "0 10px 30px rgba(0,0,0,.35)" }}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={mode}
              className="flex h-full w-full items-center justify-between px-2.5"
              initial={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
              transition={spring}
            >
              {mode === "call" && (
                <>
                  <div className="flex min-w-0 items-center gap-3 pl-1">
                    <div className="grid h-[50px] w-[50px] shrink-0 place-items-center rounded-full bg-gradient-to-b from-[#a1a1aa] to-[#52525b] text-lg font-semibold">
                      {label.charAt(0)}
                    </div>
                    <div className="min-w-0">
                      <p className="text-[12px] text-white/55">Mobil</p>
                      <p className="truncate text-[17px] font-semibold">{label}</p>
                    </div>
                  </div>
                  <div className="flex shrink-0 gap-2.5 pr-1">
                    <motion.button
                      type="button"
                      aria-label="Ablehnen"
                      whileTap={{ scale: 0.9 }}
                      transition={spring}
                      onClick={() => setMode("idle")}
                      className="grid h-[46px] w-[46px] place-items-center rounded-full bg-[#ff3b30]"
                    >
                      <PhoneOff className="h-5 w-5" fill="white" strokeWidth={1.5} />
                    </motion.button>
                    <motion.button
                      type="button"
                      aria-label="Annehmen"
                      whileTap={{ scale: 0.9 }}
                      transition={spring}
                      onClick={() => setMode("idle")}
                      className="grid h-[46px] w-[46px] place-items-center rounded-full bg-[#34c759]"
                    >
                      <Phone className="h-5 w-5" fill="white" strokeWidth={1.5} />
                    </motion.button>
                  </div>
                </>
              )}
              {mode === "music" && (
                <>
                  <div
                    className="h-[25px] w-[25px] rounded-[7px]"
                    style={{ background: "linear-gradient(135deg,#f43f5e,#8b5cf6)" }}
                  />
                  <Bars />
                </>
              )}
              {mode === "timer" && (
                <>
                  <Timer className="h-[18px] w-[18px] text-[#ff9f0a]" strokeWidth={2} />
                  <span className="pr-1.5 text-[16px] font-semibold tabular-nums text-[#ff9f0a]">
                    {Math.floor(sec / 60)}:{String(sec % 60).padStart(2, "0")}
                  </span>
                </>
              )}
            </motion.div>
          </AnimatePresence>
        </motion.div>
      </div>
      <div className="mt-16 flex flex-wrap justify-center gap-2">
        {(Object.keys(labels) as Mode[]).map((m) => (
          <motion.button
            key={m}
            type="button"
            aria-pressed={mode === m}
            onMouseMove={track}
            onClick={() => setMode(m)}
            whileTap={{ scale: 0.95 }}
            transition={spring}
            className="lg-glass lg-text h-9 rounded-full px-4 text-[14px] font-medium"
            style={mode === m ? { background: "rgba(255,255,255,.3)" } : {}}
          >
            <span className="relative z-[3]">{labels[m]}</span>
          </motion.button>
        ))}
      </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.

Markdown-Version mit dem ganzen Code, für KI-Assistenten

Mehr aus Liquid Glass

Alle ansehen: Liquid Glass

Wofür du ihn nutzen kannst

Eine Dynamic Island in React zeigt oben in deiner Web-App genau einen Live-Status: den Countdown eines Küchentimers, den Song auf der Seite einer Band oder einen Anruf in einer Produktdemo. Auf der Landingpage einer iPhone-App versteht jeder sofort, worauf sie anspielt.

Nutze sie für kurze Zustände, die man mit einem Blick erfasst, nicht für Text, den man in Ruhe lesen muss. Die Regel lautet: immer nur eine Sache. Wenn zwei Ereignisse um die Insel konkurrieren, zeig das dringendere zuerst.