A studio hero on acid lime where the cursor is a drop of black liquid. It follows the pointer on a spring with two smaller droplets trailing behind, so fast moves pull it into a tail. Every button, tag and the round stat badge is made of the same liquid, and when the drop comes close their outlines fuse into one shape. The copy sits above in a difference blend, so words turn lime wherever liquid passes under them. With no pointer the drop hops from button to button by itself. On phones the badge is hidden, the buttons wrap and the drop keeps wandering.
import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";
// Two colours run the whole piece. The copy is drawn in PAPER with a difference blend,
// so it reads as INK on the page and flips to PAPER wherever liquid sits under it.
const PAPER = "#d8ff3e";
const INK = "#0a0a0a";
/** Diameter of the cursor drop and of the two droplets that trail behind it, in pixels. */
const DROP = [78, 46, 28];
/** How much the drop swells while it sits on a button. */
const HOT_SCALE = 1.28;
/** Idle wander: seconds per hop between buttons, and the share of each hop spent travelling. */
const HOP_SECONDS = 1.7;
const TRAVEL_SHARE = 0.72;
const actions = [
{ id: "start", label: "Start a project" },
{ id: "work", label: "See the work" },
];
const services = ["Naming", "Identity", "Packaging", "Motion", "Web"];
const pill = "inline-flex h-14 items-center justify-center rounded-full px-8 text-base font-semibold";
const chip = "inline-flex h-11 items-center justify-center rounded-full px-5 text-sm font-semibold";
const round = "inline-flex h-14 w-14 items-center justify-center rounded-full";
const badge = "hidden h-64 w-64 shrink-0 flex-col items-center justify-center rounded-full text-center lg:flex";
const focus = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d8ff3e] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent";
type LayoutProps = { liquid: boolean; hot: string | null; onHot: (id: string | null, el?: HTMLElement) => void };
/**
* The page is laid out twice with the same classes. The liquid copy draws only solid ink shapes
* (it is the layer the goo filter melts together); the live copy draws only the words and links.
*/
function Layout({ liquid, hot, onHot }: LayoutProps) {
const shape = (id: string, cls: string, children: React.ReactNode, stop: boolean, label?: string) =>
liquid ? (
<motion.div key={id} data-stop={stop ? "" : undefined} className={cls} style={{ background: INK }} animate={{ scale: hot === id ? 1.07 : 1 }} transition={{ type: "spring", stiffness: 320, damping: 16 }}>
<span className="invisible">{children}</span>
</motion.div>
) : (
<a
key={id}
href="#"
aria-label={label}
className={`${cls} ${focus}`}
onPointerEnter={(e) => onHot(id, e.currentTarget)}
onPointerLeave={() => onHot(null)}
onFocus={(e) => onHot(id, e.currentTarget)}
onBlur={() => onHot(null)}
>
{children}
</a>
);
return (
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-12 px-6 py-20 sm:px-10 sm:py-24">
<div className="max-w-2xl">
<p className={`text-sm font-semibold uppercase tracking-[0.2em] ${liquid ? "invisible" : ""}`}>Fennick & Rowe, brand studio</p>
<h1 className={`mt-6 text-6xl font-black leading-[0.9] tracking-tighter sm:text-8xl ${liquid ? "invisible" : ""}`}>
Brands with
<br />a little pull.
</h1>
<p className={`mt-7 max-w-md text-lg font-medium leading-relaxed ${liquid ? "invisible" : ""}`}>
We name, design and launch products people reach for twice. Tell us what you are making and we will tell you what it wants to be.
</p>
<div className="mt-9 flex flex-wrap items-center gap-5">
{actions.map((a) => shape(a.id, pill, a.label, true))}
{shape(
"call",
round,
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M7 17 17 7M9 7h8v8" />
</svg>,
true,
"Book a call",
)}
</div>
<div className="mt-5 flex flex-wrap gap-x-4 gap-y-5">{services.map((s, i) => shape(`service-${s}`, chip, s, i === 1 || i === 3))}</div>
</div>
{shape(
"launches",
badge,
<>
<span className="text-7xl font-black tracking-tighter">214</span>
<span className="mt-2 max-w-[9rem] text-sm font-semibold leading-snug">products launched since 2016</span>
</>,
true,
)}
</div>
);
}
export default function LiquidMetaballCursorThatMergesWithButtons() {
const reduce = !!useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const liquidRef = useRef<HTMLDivElement>(null);
const steering = useRef(false);
const [ready, setReady] = useState(false);
const [hot, setHot] = useState<string | null>(null);
// One target, three springs. Each softer spring arrives later, which is what stretches the drop into a tail.
const tx = useMotionValue(0);
const ty = useMotionValue(0);
const x0 = useSpring(tx, { stiffness: 340, damping: 30, mass: 0.6 });
const y0 = useSpring(ty, { stiffness: 340, damping: 30, mass: 0.6 });
const x1 = useSpring(tx, { stiffness: 150, damping: 20, mass: 0.8 });
const y1 = useSpring(ty, { stiffness: 150, damping: 20, mass: 0.8 });
const x2 = useSpring(tx, { stiffness: 80, damping: 15, mass: 1 });
const y2 = useSpring(ty, { stiffness: 80, damping: 15, mass: 1 });
const swell = useSpring(1, { stiffness: 260, damping: 15 });
useEffect(() => {
swell.set(hot ? HOT_SCALE : 1);
}, [hot, swell]);
useEffect(() => {
const root = rootRef.current;
const liquid = liquidRef.current;
if (!root || !liquid) return;
// The wander visits a corner of each marked shape, so the drop bulges out of it instead of hiding inside.
const findStops = () => {
const base = root.getBoundingClientRect();
return Array.from(liquid.querySelectorAll<HTMLElement>("[data-stop]"))
.map((el) => el.getBoundingClientRect())
.filter((r) => r.width > 0)
.map((r, i) => ({ x: (i % 2 === 0 ? r.right - 16 : r.left + 18) - base.left, y: (i % 3 === 1 ? r.bottom - 2 : r.top + 2) - base.top }));
};
let stops = findStops();
if (stops.length) {
for (const v of [tx, x0, x1, x2]) v.jump(stops[0].x);
for (const v of [ty, y0, y1, y2]) v.jump(stops[0].y);
}
setReady(true);
if (reduce) return;
let frame = 0;
let visible = true;
const began = performance.now();
const tick = (now: number) => {
if (!steering.current && stops.length > 1) {
const t = Math.max(0, (now - began) / 1000 - 0.2);
const hop = Math.floor(t / HOP_SECONDS);
const f = Math.min(1, (t - hop * HOP_SECONDS) / (HOP_SECONDS * TRAVEL_SHARE));
const ease = f * f * (3 - 2 * f);
const a = stops[hop % stops.length];
const b = stops[(hop + 1) % stops.length];
const s = now / 1000;
tx.set(a.x + (b.x - a.x) * ease + Math.cos(s * 2.1) * 7);
ty.set(a.y + (b.y - a.y) * ease + Math.sin(s * 2.7) * 7 - Math.sin(ease * Math.PI) * 46);
}
frame = requestAnimationFrame(tick);
};
const run = () => {
cancelAnimationFrame(frame);
if (visible && !document.hidden) frame = requestAnimationFrame(tick);
};
const sizes = new ResizeObserver(() => {
stops = findStops();
});
sizes.observe(root);
const view = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
run();
});
view.observe(root);
document.addEventListener("visibilitychange", run);
run();
return () => {
cancelAnimationFrame(frame);
sizes.disconnect();
view.disconnect();
document.removeEventListener("visibilitychange", run);
};
}, [reduce, tx, ty, x0, y0, x1, y1, x2, y2]);
const steerTo = (clientX: number, clientY: number) => {
const base = rootRef.current?.getBoundingClientRect();
if (!base) return;
steering.current = true;
tx.set(clientX - base.left);
ty.set(clientY - base.top);
};
// Keyboard focus pulls the drop onto the focused link, so the effect is not pointer-only.
const onHot = (id: string | null, el?: HTMLElement) => {
setHot(id);
if (el && el.matches(":focus-visible")) {
const r = el.getBoundingClientRect();
steerTo(r.right - 16, r.top + 4);
} else if (!id && !rootRef.current?.matches(":hover")) steering.current = false;
};
const drops = [
{ x: x0, y: y0, size: DROP[0] },
{ x: x1, y: y1, size: DROP[1] },
{ x: x2, y: y2, size: DROP[2] },
];
return (
<section
ref={rootRef}
className="lmc-root relative isolate flex min-h-[660px] w-full items-center overflow-hidden"
style={{ background: PAPER }}
onPointerMove={(e) => steerTo(e.clientX, e.clientY)}
onPointerLeave={() => (steering.current = false)}
onPointerCancel={() => (steering.current = false)}
onPointerUp={(e) => {
if (e.pointerType !== "mouse") steering.current = false;
}}
>
<style>{`@media (pointer: fine) { .lmc-root, .lmc-root a { cursor: none; } }`}</style>
<svg className="absolute h-0 w-0" aria-hidden="true" focusable="false">
<defs>
{/* Blur everything, then push the alpha channel to a hard edge: shapes that were close now share one outline. */}
<filter id="lmc-goo" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="9" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 26 -12" />
</filter>
</defs>
</svg>
<div ref={liquidRef} className="pointer-events-none absolute inset-0 flex items-center" style={{ filter: "url(#lmc-goo)" }} aria-hidden="true">
<Layout liquid hot={hot} onHot={onHot} />
{drops.map((d, i) => (
<motion.div key={d.size} className="absolute left-0 top-0 rounded-full" style={{ x: d.x, y: d.y, scale: i === 0 ? swell : 1, width: d.size, height: d.size, marginLeft: -d.size / 2, marginTop: -d.size / 2, background: INK, opacity: ready ? 1 : 0 }} />
))}
</div>
<div className="relative z-10 flex w-full items-center mix-blend-difference" style={{ color: PAPER }}>
<Layout liquid={false} hot={hot} onHot={onHot} />
</div>
{/* The exact pointer position, drawn in the same blend so it shows on ink and on paper alike. */}
<motion.span className="pointer-events-none absolute left-0 top-0 z-20 -ml-1 -mt-1 h-2 w-2 rounded-full mix-blend-difference" style={{ x: x0, y: y0, background: PAPER, opacity: ready ? 1 : 0 }} aria-hidden="true" />
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/liquid-metaball-cursor-that-merges-with-buttons.json
All liquid shapes sit in one layer with an SVG filter. The filter blurs the layer, then a colour matrix multiplies the alpha channel by a large number and subtracts an offset. Blurred edges that overlap add up past the cut-off and become solid, so two nearby shapes get a single shared outline.
The filter would smear any text inside it. So one copy of the layout draws only solid shapes and goes through the filter, and a second copy with identical classes draws only the words and links on top. Because both use the same classes they line up exactly at every width.
The text layer uses `mix-blend-difference` and is coloured the same as the page. Lime minus lime is black, and lime minus black is lime, so the words invert by themselves wherever the drop is under them.
It is a blur over one layer per frame, which modern browsers handle well at hero size. Keep the filtered layer to the section, not the whole page, and avoid stacking other large blurs on top.
The fusion works on transparency, so shapes of any colour will merge, but the join shows a gradient between the two colours. A single liquid colour gives the cleanest result.
Related: Gooey Tabs with Liquid Merging Active Tab, Magnetic Button That Follows the Pointer, Custom Cursor Follower with View Label on Cards.
More cursor effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.