Liquid Metal Button

A button with animated liquid metal chrome border effect, supports pill and circle variants

buttonshaderwebglchromemetalanimated
Install dependencies
$npm install @paper-design/shaders
Preview

Source Code

"use client";

import { liquidMetalFragmentShader, ShaderMount } from "@paper-design/shaders";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";

interface LiquidMetalButtonProps {
  children?: React.ReactNode;
  icon?: React.ReactNode;
  variant?: "pill" | "circle";
  size?: "sm" | "md" | "lg";
  className?: string;
  onClick?: () => void;
}

const dims = {
  pill: {
    sm: { w: 142, h: 46, iw: 138, ih: 42 },
    md: { w: 180, h: 50, iw: 176, ih: 46 },
    lg: { w: 220, h: 56, iw: 216, ih: 52 },
  },
  circle: {
    sm: { w: 42, h: 42, iw: 38, ih: 38 },
    md: { w: 46, h: 46, iw: 42, ih: 42 },
    lg: { w: 56, h: 56, iw: 52, ih: 52 },
  },
};

export default function LiquidMetalButton({
  children,
  icon,
  variant = "pill",
  size = "md",
  className = "",
  onClick,
}: LiquidMetalButtonProps) {
  const [isHovered, setIsHovered] = useState(false);
  const [isPressed, setIsPressed] = useState(false);
  const [ripples, setRipples] = useState<
    Array<{ x: number; y: number; id: number }>
  >([]);
  const shaderRef = useRef<HTMLDivElement>(null);
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const shaderMount = useRef<any>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const rippleId = useRef(0);

  const d = dims[variant][size];

  const dimensions = useMemo(
    () => ({
      width: d.w,
      height: d.h,
      innerWidth: d.iw,
      innerHeight: d.ih,
      shaderWidth: d.w,
      shaderHeight: d.h,
    }),
    [d]
  );

  useEffect(() => {
    const styleId = "liquid-metal-btn-style";
    if (!document.getElementById(styleId)) {
      const style = document.createElement("style");
      style.id = styleId;
      style.textContent = `
        .liquid-metal-shader canvas {
          width: 100% !important;
          height: 100% !important;
          display: block !important;
          position: absolute !important;
          top: 0 !important;
          left: 0 !important;
          border-radius: 100px !important;
        }
        @keyframes lm-ripple {
          0% { transform: translate(-50%, -50%) scale(0); opacity: 0.6; }
          100% { transform: translate(-50%, -50%) scale(4); opacity: 0; }
        }
      `;
      document.head.appendChild(style);
    }

    if (shaderRef.current) {
      if (shaderMount.current?.destroy) {
        shaderMount.current.destroy();
      }

      shaderMount.current = new ShaderMount(
        shaderRef.current,
        liquidMetalFragmentShader,
        {
          u_repetition: 4,
          u_softness: 0.5,
          u_shiftRed: 0.3,
          u_shiftBlue: 0.3,
          u_distortion: 0,
          u_contour: 0,
          u_angle: 45,
          u_scale: 8,
          u_shape: 1,
          u_offsetX: 0.1,
          u_offsetY: -0.1,
        },
        undefined,
        0.6
      );
    }

    return () => {
      if (shaderMount.current?.destroy) {
        shaderMount.current.destroy();
        shaderMount.current = null;
      }
    };
  }, []);

  const handleMouseEnter = () => {
    setIsHovered(true);
    shaderMount.current?.setSpeed?.(1);
  };

  const handleMouseLeave = () => {
    setIsHovered(false);
    setIsPressed(false);
    shaderMount.current?.setSpeed?.(0.6);
  };

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (shaderMount.current?.setSpeed) {
      shaderMount.current.setSpeed(2.4);
      setTimeout(() => {
        if (isHovered) {
          shaderMount.current?.setSpeed?.(1);
        } else {
          shaderMount.current?.setSpeed?.(0.6);
        }
      }, 300);
    }

    if (buttonRef.current) {
      const rect = buttonRef.current.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const ripple = { x, y, id: rippleId.current++ };
      setRipples((prev) => [...prev, ripple]);
      setTimeout(() => {
        setRipples((prev) => prev.filter((r) => r.id !== ripple.id));
      }, 600);
    }

    onClick?.();
  };

  const pressTransform = isPressed
    ? "translateY(1px) scale(0.98)"
    : "translateY(0) scale(1)";

  return (
    <div className={`relative inline-block ${className}`}>
      <div style={{ perspective: "1000px", perspectiveOrigin: "50% 50%" }}>
        <div
          style={{
            position: "relative",
            width: dimensions.width,
            height: dimensions.height,
            transformStyle: "preserve-3d",
            transition:
              "all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)",
            transform: isHovered ? "scale(1.05)" : "scale(1)",
          }}
        >
          {/* Content layer */}
          <div
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              gap: 6,
              transformStyle: "preserve-3d",
              transform: "translateZ(20px)",
              zIndex: 30,
              pointerEvents: "none",
            }}
          >
            {variant === "circle" && icon ? (
              <span
                style={{
                  color: isHovered ? "#999" : "#666",
                  filter: "drop-shadow(0px 1px 2px rgba(0,0,0,0.5))",
                  transition: "color 0.3s ease",
                }}
              >
                {icon}
              </span>
            ) : (
              <span
                style={{
                  fontSize: 14,
                  color: isHovered ? "#999" : "#666",
                  fontWeight: 400,
                  textShadow: "0px 1px 2px rgba(0,0,0,0.5)",
                  whiteSpace: "nowrap",
                  transition: "color 0.3s ease",
                }}
              >
                {children}
              </span>
            )}
          </div>

          {/* Inner dark fill */}
          <div
            style={{
              position: "absolute",
              inset: 0,
              transformStyle: "preserve-3d",
              transition:
                "all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease",
              transform: `translateZ(10px) ${pressTransform}`,
              zIndex: 20,
            }}
          >
            <div
              style={{
                width: dimensions.innerWidth,
                height: dimensions.innerHeight,
                margin: 2,
                borderRadius: 100,
                background: "linear-gradient(180deg, #202020 0%, #000 100%)",
                boxShadow: isPressed
                  ? "inset 0 2px 4px rgba(0,0,0,0.4), inset 0 1px 2px rgba(0,0,0,0.3)"
                  : "none",
                transition:
                  "all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease",
              }}
            />
          </div>

          {/* Shader layer */}
          <div
            style={{
              position: "absolute",
              inset: 0,
              transformStyle: "preserve-3d",
              transition:
                "all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease",
              transform: `translateZ(0px) ${pressTransform}`,
              zIndex: 10,
            }}
          >
            <div
              style={{
                width: dimensions.width,
                height: dimensions.height,
                borderRadius: 100,
                boxShadow: isPressed
                  ? "0 0 0 1px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.3)"
                  : isHovered
                    ? "0 0 0 1px rgba(0,0,0,0.4), 0 12px 6px rgba(0,0,0,0.05), 0 8px 5px rgba(0,0,0,0.1), 0 4px 4px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.2)"
                    : "0 0 0 1px rgba(0,0,0,0.3), 0 36px 14px rgba(0,0,0,0.02), 0 20px 12px rgba(0,0,0,0.08), 0 9px 9px rgba(0,0,0,0.12), 0 2px 5px rgba(0,0,0,0.15)",
                transition:
                  "all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease",
                background: "transparent",
              }}
            >
              <div
                ref={shaderRef}
                className="liquid-metal-shader"
                style={{
                  borderRadius: 100,
                  overflow: "hidden",
                  position: "relative",
                  width: dimensions.shaderWidth,
                  maxWidth: dimensions.shaderWidth,
                  height: dimensions.shaderHeight,
                }}
              />
            </div>
          </div>

          {/* Click target */}
          <button
            ref={buttonRef}
            onClick={handleClick}
            onMouseEnter={handleMouseEnter}
            onMouseLeave={handleMouseLeave}
            onMouseDown={() => setIsPressed(true)}
            onMouseUp={() => setIsPressed(false)}
            style={{
              position: "absolute",
              inset: 0,
              background: "transparent",
              border: "none",
              cursor: "pointer",
              outline: "none",
              zIndex: 40,
              transformStyle: "preserve-3d",
              transform: "translateZ(25px)",
              overflow: "hidden",
              borderRadius: 100,
            }}
            aria-label={typeof children === "string" ? children : "button"}
          >
            {ripples.map((ripple) => (
              <span
                key={ripple.id}
                style={{
                  position: "absolute",
                  left: ripple.x,
                  top: ripple.y,
                  width: 20,
                  height: 20,
                  borderRadius: "50%",
                  background:
                    "radial-gradient(circle, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 70%)",
                  pointerEvents: "none",
                  animation: "lm-ripple 0.6s ease-out",
                }}
              />
            ))}
          </button>
        </div>
      </div>
    </div>
  );
}