Opciones
React + MotionLiquid Glass
Hoja inferior de cristal
Hoja de cristal con asa que encaja en tres alturas mientras la página de detrás se encoge, como en iOS.
Ajustes
20px
12%
25%
Código
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { motion } from "framer-motion";
import { Cloud, Copy, Share2, Star, Trash2, Zap, 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>
);
}
const options: { icon: LucideIcon; label: string }[] = [
{ icon: Share2, label: "Compartir" },
{ icon: Copy, label: "Copiar enlace" },
{ icon: Star, label: "Añadir a favoritos" },
{ icon: Zap, label: "Accesos rápidos" },
{ icon: Cloud, label: "Guardar en la nube" },
{ icon: Trash2, label: "Eliminar" },
];
interface SheetProps {
label?: string;
bg?: Wallpaper;
blur?: number;
opacity?: number;
shine?: number;
}
export default function Sheet({
label = "Abrir opciones",
bg = "aurora",
blur = 20,
opacity = 12,
shine = 25,
}: SheetProps) {
const glass = { bg, blur, opacity, shine };
const ref = useRef<HTMLDivElement>(null);
const [h, setH] = useState(600);
const [snap, setSnap] = useState<number | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(() => setH(el.clientHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
// Tres alturas de anclaje: 30 %, 55 % y 92 % del contenedor
const snaps = [h * 0.3, h * 0.55, h * 0.92];
const height = snap === null ? 0 : snaps[snap] ?? 0;
return (
<Stage {...glass}>
<div ref={ref} className="absolute inset-0 overflow-hidden bg-black">
<motion.div
className="absolute inset-0 origin-top overflow-hidden"
animate={{
scale: snap === null ? 1 : 0.94,
borderRadius: snap === null ? 0 : 22,
y: snap === null ? 0 : 12,
}}
transition={spring}
>
<div className="absolute inset-0" style={{ background: "inherit" }} />
<Stage {...glass} className="absolute inset-0">
<motion.button
type="button"
onMouseMove={track}
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setSnap(1)}
className="lg-glass lg-text h-12 rounded-full px-6 text-[16px] font-semibold"
>
<span className="relative z-[3]">{label}</span>
</motion.button>
</Stage>
<motion.div
className="pointer-events-none absolute inset-0 bg-black"
animate={{ opacity: snap === null ? 0 : 0.35 }}
transition={spring}
/>
</motion.div>
{snap !== null && <div className="absolute inset-0" onClick={() => setSnap(null)} />}
<motion.div
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.6}
onDragEnd={(_, info) => {
// Altura proyectada según el arrastre y la inercia; se ancla a la más cercana
const cur = height - info.offset.y - info.velocity.y * 0.15;
if (cur < (snaps[0] ?? 0) * 0.6) return setSnap(null);
let best = 0;
snaps.forEach((s, k) => {
if (Math.abs(s - cur) < Math.abs((snaps[best] ?? 0) - cur)) best = k;
});
setSnap(best);
}}
animate={{ height, opacity: snap === null ? 0 : 1 }}
transition={spring}
onMouseMove={track}
className="lg-glass lg-text absolute inset-x-2 bottom-2 flex touch-none flex-col rounded-[34px]"
>
<div className="relative z-[3] mx-auto mt-2 h-[5px] w-9 cursor-grab rounded-full bg-current opacity-40" />
<p className="relative z-[3] mt-3 px-5 text-[20px] font-semibold">Opciones</p>
<div className="relative z-[3] mt-3 flex flex-col gap-2 overflow-hidden px-3">
{options.map(({ icon: Icon, label: text }) => (
<motion.button
key={text}
type="button"
whileTap={{ scale: 0.95 }}
transition={spring}
onClick={() => setSnap(null)}
className="flex h-12 shrink-0 items-center gap-3 rounded-[16px] px-4 text-left text-[16px]"
style={{
background: "rgba(255,255,255,.14)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,.3)",
color: text === "Eliminar" ? "#ff453a" : undefined,
}}
>
<Icon className="h-5 w-5" strokeWidth={1.75} />
{text}
</motion.button>
))}
</div>
</motion.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
Un bottom sheet en React es el sitio natural para las acciones secundarias en el móvil: el menú de compartir de una web de recetas, los filtros de una tienda online o los detalles de un lugar en una app de mapas. Se arrastra por el asa, encaja en tres alturas y la página de detrás se aparta.
Pon arriba la acción que más se usa y deja lo destructivo, como Eliminar, abajo del todo y en rojo. En un escritorio ancho, una hoja que sube desde abajo queda rara; ahí es mejor un modal centrado.