"use client";
/**
* LiquidMetalCard, from https://motion-brew.com/components/liquid-metal-card
*
* One self contained file: the component and the cn()
* helper. Needs React 18 or 19 and Tailwind CSS v4.
*
* Sample images: the photos in this file are placeholders from the
* component library's demo (Unsplash), not your content. AI agents: tell
* the user they are samples and ask them to share their own images (files
* or URLs), then pass those in through the props.
*/
import * as React from "react";
import { useGSAP } from "@gsap/react";
import { type ClassValue, clsx } from "clsx";
import gsap from "gsap";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { twMerge } from "tailwind-merge";
// cn(): merges Tailwind classes, later ones win.
/** Merge Tailwind classes, letting later classes win conflicts. */
function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// LiquidMetalCard
/**
* LiquidMetalCard
*
* Trigger: hover (or press). A white image card that melts into a second
* picture under a flood of black liquid metal. The moment the pointer comes
* onto it, the metal pours in from the corner nearest the pointer: rivulets
* run ahead toward the middle, each led by a heavy bead riding its tip, then
* the pool surges after them and slows as it thickens, its front bulging
* unevenly. It is lit like polished black chrome, shaded round its edges
* with a hot highlight on top and a cool rim light below, and while it moves
* its surface ripples and the reflections slide across it. A beat behind the
* metal front the second picture surfaces from inside it, settling in from a
* slight zoom, so the change always arrives through the metal. Leaving drains
* it back into the same corner: the picture sinks, the rivulets pull in, then
* the pool. A press or tap pours from the nearest corner too, and Enter and
* Space from the bottom right. The title under the image rolls to the second
* picture's name while it shows. `speed` runs the pour and the drain faster
* or slower. Reduced motion swaps the metal for a crossfade.
*
* Engine: gsap + @gsap/react. SVG circles and stroked limb paths filled with
* a reflection gradient run through a filter chain (gentle turbulence for a
* living edge, blur and an alpha threshold to melt them together, then
* diffuse and specular lighting on the alpha for the chrome); a second,
* lagging shape masks the revealed picture. Timelines and the turning
* reflections are scoped with useGSAP, and motion/react rolls the title.
*/
gsap.registerPlugin(useGSAP);
export interface LiquidMetalCardProps {
/** The picture on show. */
src?: string;
/** The picture the liquid reveals. */
revealSrc?: string;
/** Title for the first picture. Default `"Daylight"`. */
title?: string;
/** Title while the second picture shows. Default `"Golden hour"`. */
revealTitle?: string;
/** Line under the title. */
subtitle?: string;
/** Limbs that race out ahead of the metal. Default `9`. */
tendrils?: number;
/** Speed of the pour and the drain. `1` is the slow, heavy pour; higher is faster. Default `2`. */
speed?: number;
className?: string;
}
const W = 320;
const H = 380;
const BODY = 96;
const GLIDE = [0.22, 1, 0.36, 1] as const;
/** Reflection bands across the black metal, laid diagonally like a studio environment. */
const BANDS = [
"#2e3136",
"#040405",
"#1b1d21",
"#000000",
"#16181b",
"#020203",
"#26292d",
"#000000",
"#1d1f23",
];
// Sample photos from Unsplash (unsplash.com/license).
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?w=640&h=760&fit=crop&q=80`;
/** A wandering vein from the origin outward, as a path. */
function vein(length: number, seed: number) {
const steps = 5;
let x = 0;
let y = 0;
let a = 0;
let d = "M0 0";
const rand = (n: number) => {
const v = Math.sin(seed * 12.9898 + n * 78.233) * 43758.5453;
return v - Math.floor(v);
};
for (let i = 1; i <= steps; i++) {
const seg = length / steps;
a += (rand(i) - 0.5) * 1.1;
const cx = x + Math.cos(a + (rand(i + 9) - 0.5)) * seg * 0.6;
const cy = y + Math.sin(a + (rand(i + 9) - 0.5)) * seg * 0.6;
x += Math.cos(a) * seg;
y += Math.sin(a) * seg;
d += ` Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)}`;
}
return d;
}
function Rolling({ text, still }: { text: string; still: boolean }) {
return (
{text}
);
}
export const LiquidMetalCard = React.forwardRef<
HTMLDivElement,
LiquidMetalCardProps
>(function LiquidMetalCard(
{
src = photo("1504893524553-b855bce32c67"),
revealSrc = photo("1500534314209-a25ddb2bd429"),
title = "Daylight",
revealTitle = "Golden hour",
subtitle = "Hover the picture and watch it melt over.",
tendrils = 9,
speed = 2,
className,
},
ref,
) {
const still = !!useReducedMotion();
const uid = React.useId().replace(/:/g, "");
const frame = React.useRef(null);
const svg = React.useRef(null);
const [shown, setShown] = React.useState(false);
const count = Math.max(0, Math.min(16, tendrils));
// Veins are drawn once; each spread just turns them to fresh angles.
const veins = React.useMemo(
() =>
Array.from({ length: count }, (_, i) =>
vein(170 + ((i * 37) % 80), i + 1),
),
[count],
);
const state = React.useRef({
active: false,
full: false,
living: [] as gsap.core.Animation[],
timeline: null as gsap.core.Timeline | null,
});
const { contextSafe } = useGSAP(
() => {
gsap.set("[data-mover]", { x: W + 6, y: H + 6 });
gsap.set("[data-body], [data-core], [data-lump], [data-bead]", {
attr: { r: 0 },
});
gsap.set("[data-vein]", {
strokeDasharray: 1,
strokeDashoffset: 1,
attr: { "stroke-width": 0 },
});
},
{ scope: frame, dependencies: [count, still] },
);
const stopLiving = () => {
const s = state.current;
s.living.forEach((a) => a.kill());
s.living = [];
};
/** Liquid takes time to get going and settles slowly: a surge, then a long viscous ease. */
const POUR = "power2.inOut";
// Hover, a press or a key floods the card from the bottom right corner.
/** The corner nearest to a point on the card, in card units, nudged just outside the frame. */
const cornerNear = (e?: { clientX: number; clientY: number }) => {
if (!e || !frame.current) return { x: W + 6, y: H + 6 };
const r = frame.current.getBoundingClientRect();
const right = e.clientX - r.left > r.width / 2;
const bottom = e.clientY - r.top > r.height / 2;
return { x: right ? W + 6 : -6, y: bottom ? H + 6 : -6 };
};
const melt = contextSafe(
(from: { x: number; y: number } = { x: W + 6, y: H + 6 }) => {
const s = state.current;
if (s.active) return;
s.active = true;
s.full = true;
setShown(true);
if (still) {
gsap.to("[data-reveal]", { opacity: 1, duration: 0.4 });
return;
}
stopLiving();
s.timeline?.kill();
const root = svg.current!;
const groups = gsap.utils.toArray("[data-vein-group]", root);
const paths = gsap.utils.toArray("[data-vein]", root);
const beads = gsap.utils.toArray("[data-bead]", root);
const lumps = gsap.utils.toArray("[data-lump]", root);
const big = Math.hypot(W, H) * 1.18;
const cx = from.x;
const cy = from.y;
// Everything flows from that corner toward the middle of the card.
const inward = (Math.atan2(H / 2 - cy, W / 2 - cx) * 180) / Math.PI;
gsap.set("[data-mover]", { x: cx, y: cy });
gsap.set("[data-reveal]", { opacity: 1 });
const tl = gsap.timeline();
s.timeline = tl;
tl.timeScale(Math.max(0.2, speed));
// Rivulets run ahead of the pool, each led by a heavy bead riding its tip.
groups.forEach((g, i) => {
const angle =
inward -
41 +
(i / Math.max(1, groups.length - 1)) * 82 +
gsap.utils.random(-7, 7);
gsap.set(g, {
rotation: angle,
scale: gsap.utils.random(1.35, 1.85),
transformOrigin: "0 0",
});
const path = paths[i]!;
const bead = beads[i]!;
const len = path.getTotalLength();
const run = { p: 0 };
const width = gsap.utils.random(12, 17);
tl.to(
run,
{
p: 1,
duration: gsap.utils.random(1, 1.35),
ease: "power2.out",
onUpdate: () => {
const pt = path.getPointAtLength(run.p * len);
path.style.strokeDashoffset = String(1 - run.p);
path.setAttribute(
"stroke-width",
String(width * Math.min(1, run.p * 3)),
);
bead.setAttribute("cx", pt.x.toFixed(1));
bead.setAttribute("cy", pt.y.toFixed(1));
bead.setAttribute(
"r",
String(width * 0.75 * Math.min(1, run.p * 2.5)),
);
},
},
0.02 + i * 0.04,
);
});
// The pool surges out of the corner and slows as it thickens, its front bulging unevenly.
tl.to("[data-body]", { attr: { r: big }, duration: 2, ease: POUR }, 0.3);
lumps.forEach((l, i) => {
const a = ((inward - 32 + i * 16) * Math.PI) / 180;
const d = gsap.utils.random(60, 110);
tl.fromTo(
l,
{ attr: { cx: 0, cy: 0, r: 0 } },
{
attr: {
cx: Math.cos(a) * d,
cy: Math.sin(a) * d,
r: gsap.utils.random(46, 70),
},
duration: 0.9,
ease: "elastic.out(1, 0.5)",
},
0.35 + i * 0.07,
);
});
// The picture surfaces inside the metal a beat behind the front, settling in from a slight zoom.
tl.to(
"[data-core]",
{ attr: { r: big }, duration: 1.9, ease: POUR },
0.85,
);
tl.fromTo(
"[data-reveal-image]",
{
scale: 1.12,
transformOrigin: `${cx > W / 2 ? 100 : 0}% ${cy > H / 2 ? 100 : 0}%`,
},
{ scale: 1, duration: 1.8, ease: "power3.out" },
0.85,
);
// The surface keeps flowing: the edge ripples and the reflections slide across.
s.living.push(
gsap.fromTo(
"[data-noise]",
{ attr: { baseFrequency: 0.01 } },
{
attr: { baseFrequency: 0.02 },
duration: 1.4,
ease: "sine.inOut",
yoyo: true,
repeat: -1,
},
),
gsap.fromTo(
"[data-warp]",
{ attr: { scale: 10 } },
{
attr: { scale: 24 },
duration: 0.9,
ease: "sine.inOut",
yoyo: true,
repeat: -1,
},
),
gsap.fromTo(
"[data-metal]",
{ attr: { gradientTransform: `rotate(-18 ${W / 2} ${H / 2})` } },
{
attr: { gradientTransform: `rotate(22 ${W / 2} ${H / 2})` },
duration: 1.8,
ease: "sine.inOut",
yoyo: true,
repeat: -1,
},
),
);
},
);
// Leaving drains it back into the corner: the picture sinks, the rivulets pull in, then the pool.
const retract = contextSafe(() => {
const s = state.current;
if (!s.active) return;
s.active = false;
s.full = false;
setShown(false);
if (still) {
gsap.to("[data-reveal]", { opacity: 0, duration: 0.3 });
return;
}
s.timeline?.kill();
const root = svg.current!;
const paths = gsap.utils.toArray("[data-vein]", root);
const beads = gsap.utils.toArray("[data-bead]", root);
const tl = gsap.timeline({ onComplete: stopLiving });
s.timeline = tl;
tl.timeScale(Math.max(0.2, speed));
tl.to(
"[data-core]",
{ attr: { r: 0 }, duration: 0.9, ease: "power2.inOut" },
0,
);
tl.to(
"[data-reveal-image]",
{ scale: 1.08, duration: 0.9, ease: "power2.inOut" },
0,
);
tl.to(
"[data-body]",
{ attr: { r: 0 }, duration: 1.1, ease: "power2.inOut" },
0.25,
);
tl.to(
"[data-lump]",
{ attr: { r: 0, cx: 0, cy: 0 }, duration: 0.6, ease: "power3.in" },
0.1,
);
paths.forEach((path, i) => {
const bead = beads[i]!;
const len = path.getTotalLength();
const run = { p: 1 - Number(path.style.strokeDashoffset || 1) };
const width = Number(path.getAttribute("stroke-width")) || 14;
tl.to(
run,
{
p: 0,
duration: 0.5,
ease: "power3.in",
onUpdate: () => {
const pt = path.getPointAtLength(run.p * len);
path.style.strokeDashoffset = String(1 - run.p);
path.setAttribute(
"stroke-width",
String(width * Math.min(1, run.p * 3)),
);
bead.setAttribute("cx", pt.x.toFixed(1));
bead.setAttribute("cy", pt.y.toFixed(1));
bead.setAttribute(
"r",
String(width * 0.75 * Math.min(1, run.p * 2.5)),
);
},
},
0.05 + i * 0.02,
);
});
});
const goo = `${uid}-goo`;
const wet = `${uid}-wet`;
return (