Mareas

Luna Vega

1:08-2:25
React + MotionLiquid Glass

Reproductor de música

Un reproductor con portada que brilla, barra de progreso que engorda al tocarla y fondo que toma el color del disco.

Ajustes

20px
12%
25%

Código

import { useEffect, useState, type PointerEvent, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { motion } from "framer-motion";
import { Music2, Pause, Play, SkipBack, SkipForward, Volume2 } 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>
  );
}

const covers = [
  { t: "Mareas", a: "Luna Vega", c: ["#f43f5e", "#8b5cf6", "#f97316"] },
  { t: "Horizonte", a: "Norte", c: ["#06b6d4", "#3b82f6", "#10b981"] },
  { t: "Terciopelo", a: "Alba Ríos", c: ["#f59e0b", "#ec4899", "#7c3aed"] },
];

const DURATION = 214;

const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

function seek(e: PointerEvent<HTMLDivElement>, set: (n: number) => void) {
  const r = e.currentTarget.getBoundingClientRect();
  set(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)));
}

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

export default function MusicPlayer({
  label = "Mareas",
  bg = "aurora",
  blur = 20,
  opacity = 12,
  shine = 25,
}: MusicPlayerProps) {
  const glass = { bg, blur, opacity, shine };
  const [index, setIndex] = useState(0);
  const [play, setPlay] = useState(false);
  const [progress, setProgress] = useState(0.32);
  const [volume, setVolume] = useState(0.6);
  const [grab, setGrab] = useState(false);
  const cover = covers[index] ?? covers[0];
  const c = cover ? cover.c : ["#fff", "#fff", "#fff"];

  useEffect(() => {
    if (!play) return;
    const t = setInterval(() => setProgress((x) => (x >= 1 ? 0 : x + 0.004)), 250);
    return () => clearInterval(t);
  }, [play]);

  return (
    <Stage {...glass}>
      {/* El fondo toma el color de la portada */}
      <motion.div
        className="pointer-events-none absolute inset-0"
        animate={{
          background: `radial-gradient(60% 60% at 50% 40%, ${c[0]}88, transparent 70%), radial-gradient(50% 50% at 80% 80%, ${c[1]}66, transparent 70%)`,
        }}
        transition={{ duration: 0.8 }}
      />
      <div
        onMouseMove={track}
        className="lg-glass lg-text flex w-[340px] max-w-[calc(100vw-48px)] flex-col rounded-[34px] p-6"
        style={{ height: 480 }}
      >
        <div className="relative z-[3] grid place-items-center">
          <motion.div
            animate={{ scale: play ? 1 : 0.88 }}
            transition={spring}
            className="relative aspect-square w-full overflow-hidden rounded-[22px]"
            style={{ boxShadow: `0 20px 50px ${c[0]}80` }}
          >
            <motion.div
              className="absolute -inset-1/2"
              animate={{ rotate: 360 }}
              transition={{ duration: 18, repeat: Infinity, ease: "linear" }}
              style={{
                background: `conic-gradient(from 0deg, ${c[0]}, ${c[1]}, ${c[2]}, ${c[0]})`,
                filter: "blur(30px)",
              }}
            />
            <div className="absolute inset-0 grid place-items-center">
              <Music2 className="h-14 w-14 text-white/70" strokeWidth={1.25} />
            </div>
          </motion.div>
        </div>

        <div className="relative z-[3] mt-5 flex items-end justify-between">
          <div className="min-w-0">
            <p className="truncate text-[20px] font-semibold tracking-[-0.01em]">
              {index === 0 ? label : cover?.t}
            </p>
            <p className="text-[15px] opacity-65">{cover?.a}</p>
          </div>
          <div className="flex h-6 items-end gap-[3px]">
            {[0, 1, 2, 3, 4].map((k) => (
              <motion.span
                key={k}
                className="w-[3px] rounded-full bg-current"
                animate={{ height: play ? [4, 20, 8, 16, 4] : 3, opacity: play ? 0.9 : 0.3 }}
                transition={play ? { duration: 0.7 + k * 0.12, repeat: Infinity, ease: "easeInOut" } : spring}
              />
            ))}
          </div>
        </div>

        <div className="relative z-[3] mt-4">
          <div
            className="flex h-5 cursor-pointer touch-none items-center"
            onPointerEnter={() => setGrab(true)}
            onPointerLeave={() => setGrab(false)}
            onPointerDown={(e) => {
              e.currentTarget.setPointerCapture(e.pointerId);
              seek(e, setProgress);
            }}
            onPointerMove={(e) => {
              if (e.buttons) seek(e, setProgress);
            }}
          >
            <motion.div
              animate={{ height: grab ? 10 : 5 }}
              transition={spring}
              className="w-full overflow-hidden rounded-full bg-white/25"
            >
              <div className="h-full bg-white/90" style={{ width: `${progress * 100}%` }} />
            </motion.div>
          </div>
          <div className="flex justify-between text-[11px] tabular-nums opacity-60">
            <span>{fmt(progress * DURATION)}</span>
            <span>-{fmt((1 - progress) * DURATION)}</span>
          </div>
        </div>

        <div className="relative z-[3] mt-2 flex items-center justify-center gap-10">
          <motion.button
            type="button"
            aria-label="Anterior"
            transition={spring}
            whileTap={{ scale: 0.85 }}
            onClick={() => {
              setIndex((x) => (x + 2) % 3);
              setProgress(0);
            }}
          >
            <SkipBack className="h-7 w-7" fill="currentColor" strokeWidth={1.75} />
          </motion.button>
          <motion.button
            type="button"
            aria-label={play ? "Pausar" : "Reproducir"}
            transition={spring}
            whileTap={{ scale: 0.85 }}
            onClick={() => setPlay((x) => !x)}
          >
            {play ? (
              <Pause className="h-10 w-10" fill="currentColor" strokeWidth={1.75} />
            ) : (
              <Play className="h-10 w-10" fill="currentColor" strokeWidth={1.75} />
            )}
          </motion.button>
          <motion.button
            type="button"
            aria-label="Siguiente"
            transition={spring}
            whileTap={{ scale: 0.85 }}
            onClick={() => {
              setIndex((x) => (x + 1) % 3);
              setProgress(0);
            }}
          >
            <SkipForward className="h-7 w-7" fill="currentColor" strokeWidth={1.75} />
          </motion.button>
        </div>

        <div className="relative z-[3] mt-auto flex items-center gap-3">
          <Volume2 className="h-4 w-4 opacity-60" strokeWidth={1.75} />
          <div
            className="flex h-5 flex-1 cursor-pointer touch-none items-center"
            onPointerDown={(e) => {
              e.currentTarget.setPointerCapture(e.pointerId);
              seek(e, setVolume);
            }}
            onPointerMove={(e) => {
              if (e.buttons) seek(e, setVolume);
            }}
          >
            <div className="h-[5px] w-full overflow-hidden rounded-full bg-white/25">
              <div className="h-full bg-white/90" style={{ width: `${volume * 100}%` }} />
            </div>
          </div>
        </div>
      </div>
    </Stage>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Liquid Glass

Dónde usarlo

Esta interfaz de reproductor de música encaja en cualquier web donde la gente pulse play: la página de un grupo o de un DJ, la ficha de un episodio de pódcast, una app de meditación o la playlist de una web de boda. El fondo coge los colores de cada portada y la barra crece al pasar por encima.

Conéctalo a un elemento de audio real y el tiempo, el avance y el volumen encajan casi uno a uno. El brillo luce más con portadas vivas y de colores; con portadas oscuras, baja el desenfoque para que la tarjeta no quede turbia.