Liquid Metal Button
A button with animated liquid metal chrome border effect, supports pill and circle variants
buttonshaderwebglchromemetalanimated
Install dependencies
$npm install @paper-design/shadersPreview
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>
);
}