Liquid Button
A pill button with a magnetic liquid border — the shape morphs toward the cursor on hover, with a cyan liquid blob that spills out at the border edge.
buttonliquidmagneticmorphingsvganimationframer-motion
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useRef, useEffect, useState } from "react";
import { useMotionValue, useSpring } from "framer-motion";
const SAMPLES = 64;
const PAD = 50;
const SPRING_OPTS = { stiffness: 200, damping: 20 };
const STR_OPTS = { stiffness: 90, damping: 18 };
function pillPoint(t: number, w: number, h: number): [number, number] {
const r = h / 2;
const seg = w - h;
const arc = Math.PI * r;
const total = 2 * seg + 2 * arc;
let p = ((t % 1 + 1) % 1) * total;
if (p < seg) return [r + p, 0];
p -= seg;
if (p < arc) {
const a = -Math.PI / 2 + (p / arc) * Math.PI;
return [w - r + Math.cos(a) * r, r + Math.sin(a) * r];
}
p -= arc;
if (p < seg) return [w - r - p, h];
p -= seg;
const a = Math.PI / 2 + (p / arc) * Math.PI;
return [r + Math.cos(a) * r, r + Math.sin(a) * r];
}
function toPath(pts: [number, number][]): string {
const n = pts.length;
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
for (let i = 0; i < n; i++) {
const [x0, y0] = pts[(i - 1 + n) % n];
const [x1, y1] = pts[i];
const [x2, y2] = pts[(i + 1) % n];
const [x3, y3] = pts[(i + 2) % n];
d += `C${(x1 + (x2 - x0) / 6).toFixed(1)},${(y1 + (y2 - y0) / 6).toFixed(1)},${(x2 - (x3 - x1) / 6).toFixed(1)},${(y2 - (y3 - y1) / 6).toFixed(1)},${x2.toFixed(1)},${y2.toFixed(1)}`;
}
return d + "Z";
}
function buildPath(
w: number,
h: number,
mx: number,
my: number,
maxDeform: number
): string {
if (w <= 0 || h <= 0) return "";
const sigma = Math.min(w, h) * 0.35;
const cx = w / 2 + PAD;
const cy = h / 2 + PAD;
const pts: [number, number][] = Array.from({ length: SAMPLES }, (_, i) => {
const [bx, by] = pillPoint(i / SAMPLES, w, h);
const px = bx + PAD;
const py = by + PAD;
if (maxDeform < 0.5) return [px, py];
const dx = mx - px;
const dy = my - py;
const dist = Math.sqrt(dx * dx + dy * dy);
const force = maxDeform * Math.exp(-(dist * dist) / (2 * sigma * sigma));
const ox = px - cx;
const oy = py - cy;
const ol = Math.sqrt(ox * ox + oy * oy) || 1;
return [px + (ox / ol) * force, py + (oy / ol) * force];
});
return toPath(pts);
}
interface LiquidButtonProps {
children?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function LiquidButton({
children = "Liquid Button",
onClick,
className,
}: LiquidButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);
const bgRef = useRef<SVGPathElement>(null);
const strokeRef = useRef<SVGPathElement>(null);
const dimsRef = useRef({ w: 0, h: 0 });
const [svgSize, setSvgSize] = useState({ w: 0, h: 0 });
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const rawStr = useMotionValue(0);
const mx = useSpring(rawX, SPRING_OPTS);
const my = useSpring(rawY, SPRING_OPTS);
const str = useSpring(rawStr, STR_OPTS);
// Animate every spring tick — update SVG attributes directly
useEffect(() => {
const redraw = () => {
const { w, h } = dimsRef.current;
if (!w || !h) return;
const cx = mx.get();
const cy = my.get();
const cs = str.get();
const d = buildPath(w, h, cx, cy, cs * 20);
bgRef.current?.setAttribute("d", d);
strokeRef.current?.setAttribute("d", d);
};
const subs = [
mx.on("change", redraw),
my.on("change", redraw),
str.on("change", redraw),
];
return () => subs.forEach((u) => u());
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Track the full button size (including padding), rebuild static clip
useEffect(() => {
const el = btnRef.current;
if (!el) return;
const obs = new ResizeObserver(() => {
// getBoundingClientRect gives content + padding (the visible button area)
const { width: w, height: h } = el.getBoundingClientRect();
dimsRef.current = { w, h };
setSvgSize({ w: w + 2 * PAD, h: h + 2 * PAD });
rawX.set(w / 2 + PAD);
rawY.set(h / 2 + PAD);
});
obs.observe(el);
return () => obs.disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleMouseMove = (e: React.MouseEvent) => {
const rect = btnRef.current!.getBoundingClientRect();
rawX.set(e.clientX - rect.left + PAD);
rawY.set(e.clientY - rect.top + PAD);
};
return (
<button
ref={btnRef}
onClick={onClick}
onMouseMove={handleMouseMove}
onMouseEnter={() => rawStr.set(1)}
onMouseLeave={() => rawStr.set(0)}
className={`relative inline-flex cursor-pointer items-center justify-center bg-transparent outline-none ${className ?? ""}`}
style={{ padding: "14px 44px", border: "none" }}
>
<svg
style={{
position: "absolute",
left: -PAD,
top: -PAD,
overflow: "visible",
pointerEvents: "none",
}}
width={svgSize.w || 1}
height={svgSize.h || 1}
>
{/* Dark button background (deformed pill) */}
<path ref={bgRef} d="" fill="#0a0a0a" />
{/* White border (deformed pill outline) */}
<path
ref={strokeRef}
d=""
fill="none"
stroke="rgba(255,255,255,0.82)"
strokeWidth="1.5"
/>
</svg>
<span className="relative z-10 select-none whitespace-nowrap font-mono text-[15px] tracking-wide text-white">
{children}
</span>
</button>
);
}
export default LiquidButton;