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-motion
Preview

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;