Mareas
Nova
Centro de control de cristal
Módulos de cristal para wifi, bluetooth, brillo, volumen y música, con sliders que se arrastran de verdad.
Ajustes
Código
import { useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { motion } from "framer-motion";
import { Bluetooth, Calculator, Camera, Flashlight, Moon, Pause, Plane, Play, Signal, SkipBack, SkipForward, Sun, Volume2, Wifi, 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>
);
}
// Deslizador vertical de brillo / volumen: arrastra o pulsa para fijar el nivel
function VSlider({ icon: Icon, init }: { icon: LucideIcon; init: number }) {
const [value, setValue] = useState(init);
const ref = useRef<HTMLDivElement>(null);
const set = (y: number) => {
const r = ref.current?.getBoundingClientRect();
if (!r) return;
setValue(Math.max(0, Math.min(1, 1 - (y - r.top) / r.height)));
};
return (
<motion.div
ref={ref}
onMouseMove={track}
whileTap={{ scale: 1.04 }}
transition={spring}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
set(e.clientY);
}}
onPointerMove={(e) => {
if (e.buttons) set(e.clientY);
}}
className="lg-glass lg-text h-[168px] w-[72px] cursor-grab touch-none rounded-[26px]"
>
<motion.div
className="absolute inset-x-0 bottom-0 z-[2] bg-white/90"
animate={{ height: `${value * 100}%` }}
transition={spring}
/>
<Icon
className="absolute bottom-4 left-1/2 z-[3] h-6 w-6 -translate-x-1/2 text-[#1d1d1f]/70"
strokeWidth={1.75}
/>
</motion.div>
);
}
interface RoundProps {
icon: LucideIcon;
label: string;
on: boolean;
onClick: () => void;
color?: string;
}
function Round({ icon: Icon, label, on, onClick, color = "#fff" }: RoundProps) {
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={on}
onMouseMove={track}
onClick={onClick}
whileTap={{ scale: 0.95 }}
transition={spring}
className="lg-glass lg-text grid h-[72px] w-[72px] place-items-center rounded-full"
style={on ? { background: color } : {}}
>
<Icon
className="relative z-[3] h-6 w-6"
strokeWidth={1.75}
style={on ? { color: color === "#fff" ? "#1d1d1f" : "#fff", textShadow: "none" } : {}}
/>
</motion.button>
);
}
type Conn = "plane" | "data" | "wifi" | "bt";
const connections: { key: Conn; icon: LucideIcon; color: string; label: string }[] = [
{ key: "plane", icon: Plane, color: "#ff9f0a", label: "Modo avión" },
{ key: "data", icon: Signal, color: "#34c759", label: "Datos móviles" },
{ key: "wifi", icon: Wifi, color: "#0a84ff", label: "Wi-Fi" },
{ key: "bt", icon: Bluetooth, color: "#0a84ff", label: "Bluetooth" },
];
interface ControlCenterProps {
label?: string;
bg?: Wallpaper;
blur?: number;
opacity?: number;
shine?: number;
}
export default function ControlCenter({
label = "Mareas",
bg = "aurora",
blur = 20,
opacity = 12,
shine = 25,
}: ControlCenterProps) {
const glass = { bg, blur, opacity, shine };
const [conn, setConn] = useState<Record<Conn, boolean>>({ plane: false, data: true, wifi: true, bt: true });
const [round, setRound] = useState({ torch: false, cam: false, calc: false });
const [playing, setPlaying] = useState(true);
return (
<Stage {...glass}>
<div className="grid w-[320px] grid-cols-[152px_152px] gap-4 max-sm:scale-90">
<div
onMouseMove={track}
className="lg-glass lg-text grid h-[152px] grid-cols-2 place-items-center rounded-[30px] p-3"
>
{connections.map(({ key, icon: Icon, color, label }) => (
<motion.button
key={key}
type="button"
aria-label={label}
aria-pressed={conn[key]}
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setConn((s) => ({ ...s, [key]: !s[key] }))}
className="relative z-[3] grid h-[54px] w-[54px] place-items-center rounded-full"
animate={{ backgroundColor: conn[key] ? color : "rgba(255,255,255,0.18)" }}
>
<Icon className="h-[22px] w-[22px] text-white" strokeWidth={1.75} />
</motion.button>
))}
</div>
<div onMouseMove={track} className="lg-glass lg-text flex h-[152px] flex-col rounded-[30px] p-4">
<div className="relative z-[3] flex items-center gap-2.5">
<motion.div
animate={{ rotate: playing ? 360 : 0 }}
transition={playing ? { duration: 6, repeat: Infinity, ease: "linear" } : spring}
className="h-11 w-11 shrink-0 rounded-full"
style={{
background: "conic-gradient(from 0deg,#f43f5e,#8b5cf6,#06b6d4,#f43f5e)",
boxShadow: "inset 0 0 0 14px rgba(0,0,0,.15)",
}}
/>
<div className="min-w-0">
<p className="truncate text-[13px] font-semibold">{label}</p>
<p className="text-[12px] opacity-60">Nova</p>
</div>
</div>
<div className="relative z-[3] mt-auto flex h-5 items-end justify-center gap-[3px]">
{Array.from({ length: 12 }).map((_, i) => (
<motion.span
key={i}
className="w-[3px] rounded-full bg-current opacity-80"
animate={{ height: playing ? [4, 18, 7, 14, 4] : 4 }}
transition={
playing ? { duration: 0.8 + (i % 4) * 0.15, repeat: Infinity, ease: "easeInOut" } : spring
}
/>
))}
</div>
<div className="relative z-[3] mt-2 flex items-center justify-center gap-5">
<motion.button type="button" aria-label="Anterior" whileTap={{ scale: 0.95 }} transition={spring}>
<SkipBack className="h-5 w-5" fill="currentColor" strokeWidth={1.75} />
</motion.button>
<motion.button
type="button"
aria-label={playing ? "Pausar" : "Reproducir"}
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setPlaying((p) => !p)}
>
{playing ? (
<Pause className="h-6 w-6" fill="currentColor" strokeWidth={1.75} />
) : (
<Play className="h-6 w-6" fill="currentColor" strokeWidth={1.75} />
)}
</motion.button>
<motion.button type="button" aria-label="Siguiente" whileTap={{ scale: 0.95 }} transition={spring}>
<SkipForward className="h-5 w-5" fill="currentColor" strokeWidth={1.75} />
</motion.button>
</div>
</div>
<div className="flex gap-2">
<VSlider icon={Sun} init={0.7} />
<VSlider icon={Volume2} init={0.45} />
</div>
<div className="grid grid-cols-2 place-items-center gap-2">
<Round
icon={Flashlight}
label="Linterna"
on={round.torch}
onClick={() => setRound((s) => ({ ...s, torch: !s.torch }))}
/>
<Round
icon={Camera}
label="Cámara"
on={round.cam}
onClick={() => setRound((s) => ({ ...s, cam: !s.cam }))}
/>
<Round
icon={Calculator}
label="Calculadora"
on={round.calc}
color="#ff9f0a"
onClick={() => setRound((s) => ({ ...s, calc: !s.calc }))}
/>
<Round icon={Moon} label="No molestar" on={false} onClick={() => undefined} />
</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
Ver todos: Liquid GlassDónde usarlo
Este centro de control estilo iOS en React es un panel de pequeños controles listo para usar: interruptores de conexión, una tarjeta de lo que suena, sliders verticales de brillo y volumen y accesos redondos. Encaja en el panel de una casa domótica, los ajustes rápidos de un quiosco o un concepto para tu portfolio.
No hace falta usar la rejilla entera. El slider vertical por sí solo queda genial como control de volumen o de intensidad de luz, y los botones redondos sirven como accesos rápidos en cualquier parte. Cambia los iconos por lo que más toque tu gente.