"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
export interface LiquidButtonProps extends React.ComponentPropsWithoutRef<"button"> {
/** Colour of the liquid fill (any CSS colour). */
accent?: string;
/** Colour of the label while the button is filled. Pick something that contrasts with `accent`. */
inkColor?: string;
/** Fill / drain duration in milliseconds. */
duration?: number;
/** Shows a spinner, sets aria-busy and blocks activation. */
loading?: boolean;
/** Screen-reader text appended to the accessible name while loading. */
loadingLabel?: string;
/** Morph the edge of the fill like a liquid meniscus. */
wobble?: boolean;
/** Base appearance before the fill arrives. */
variant?: "outline" | "ink";
/** Corner radius in px. */
rounded?: number;
}
type Ripple = { id: number; x: number; y: number; d: number };
// Organic border-radius keyframes: the blob never settles into a perfect circle.
const BLOB = [
"42% 58% 61% 39% / 47% 41% 59% 53%",
"58% 42% 39% 61% / 55% 61% 39% 45%",
"49% 51% 56% 44% / 39% 57% 43% 61%",
"42% 58% 61% 39% / 47% 41% 59% 53%",
];
const EASE: [number, number, number, number] = [0.65, 0, 0.35, 1];
/** Point (px, relative to the element) plus the diameter needed to reach its farthest corner. */
function measure(el: HTMLElement, clientX?: number, clientY?: number) {
const rect = el.getBoundingClientRect();
const x = clientX === undefined ? rect.width / 2 : clientX - rect.left;
const y = clientY === undefined ? rect.height / 2 : clientY - rect.top;
const d = 2 * Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y));
return { x, y, d };
}
/** Writes the fill origin to CSS variables, so pointer tracking never re-renders. */
function placeOrigin(el: HTMLElement, clientX?: number, clientY?: number) {
const { x, y, d } = measure(el, clientX, clientY);
el.style.setProperty("--lb-x", `${x}px`);
el.style.setProperty("--lb-y", `${y}px`);
el.style.setProperty("--lb-d", `${d}px`);
}
/**
* A button flooded with colour from wherever the pointer enters, draining back out
* through the exit point. Keyboard focus fills from the centre.
*/
export const LiquidButton = React.forwardRef<HTMLButtonElement, LiquidButtonProps>(function LiquidButton(
{
accent = "#FF7448",
inkColor = "#0B0D0F",
duration = 600,
loading = false,
loadingLabel = "Loading",
wobble = true,
variant = "outline",
rounded = 999,
className = "",
style,
disabled,
children,
onClick,
onPointerEnter,
onPointerLeave,
onPointerDown,
onFocus,
onBlur,
...props
},
ref,
) {
const reduceMotion = useReducedMotion();
const innerRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
const [filled, setFilled] = React.useState(false);
const [ripples, setRipples] = React.useState<Ripple[]>([]);
// True once the fill has fully covered the button.
const fullRef = React.useRef(false);
const rippleId = React.useRef(0);
const showFill = (filled || loading) && !disabled;
const seconds = Math.max(duration, 0) / 1000;
const animateBlob = wobble && !reduceMotion && showFill;
// Fires once the fill has had time to cover the button completely.
const fullTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => () => clearTimeout(fullTimer.current), []);
// A loading state set programmatically (no hover) still needs a sensible origin.
React.useEffect(() => {
if (loading && !filled && innerRef.current) placeOrigin(innerRef.current);
}, [loading, filled]);
const fill = (clientX?: number, clientY?: number) => {
if (disabled || !innerRef.current) return;
fullRef.current = false;
placeOrigin(innerRef.current, clientX, clientY);
setFilled(true);
clearTimeout(fullTimer.current);
fullTimer.current = setTimeout(() => {
fullRef.current = true;
}, Math.max(duration, 0) * 1.1);
};
const drain = (clientX?: number, clientY?: number) => {
clearTimeout(fullTimer.current);
// Only re-anchor at the exit point when the button is fully covered, otherwise the blob would jump.
if (fullRef.current && innerRef.current) placeOrigin(innerRef.current, clientX, clientY);
fullRef.current = false;
setFilled(false);
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (loading) {
// Blocks activation (including form submission) without removing focus.
event.preventDefault();
return;
}
onClick?.(event);
};
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
onPointerDown?.(event);
if (loading || disabled || reduceMotion) return;
if (!innerRef.current) return;
const { x, y, d } = measure(innerRef.current, event.clientX, event.clientY);
rippleId.current += 1;
const id = rippleId.current;
setRipples((list) => [...list.slice(-3), { id, x, y, d }]);
};
const fillScale = wobble && !reduceMotion ? 1.18 : 1.02;
const fillTransition = (lead: boolean) => {
if (reduceMotion) return { opacity: { duration: 0.15 } };
// The pale "meniscus" layer leads on the way in and trails on the way out.
const scaleDuration = showFill ? seconds * (lead ? 0.72 : 1) : seconds * (lead ? 1 : 0.72);
return {
scale: { duration: scaleDuration, ease: EASE, delay: showFill && !lead ? seconds * 0.06 : 0 },
borderRadius: animateBlob ? { duration: 2.6, ease: "easeInOut" as const, repeat: Infinity } : { duration: 0.3 },
};
};
const layer = (lead: boolean) => (
<motion.span
className="absolute left-[var(--lb-x,50%)] top-[var(--lb-y,50%)] h-[var(--lb-d,200%)] w-[var(--lb-d,200%)] will-change-transform"
style={{
x: "-50%",
y: "-50%",
background: lead ? "color-mix(in oklab, var(--lb-accent) 42%, transparent)" : "var(--lb-accent)",
}}
initial={false}
animate={
reduceMotion
? { opacity: showFill ? 1 : 0, scale: fillScale, borderRadius: "50%" }
: { scale: showFill ? fillScale : 0, borderRadius: animateBlob ? BLOB : "50%" }
}
transition={fillTransition(lead)}
/>
);
const variantClass =
variant === "ink"
? "bg-zinc-950 text-white dark:bg-white dark:text-zinc-950"
: "border border-zinc-900/15 bg-white/50 text-zinc-900 dark:border-white/15 dark:bg-white/[0.03] dark:text-zinc-100";
return (
<button
ref={innerRef}
type="button"
disabled={disabled}
aria-busy={loading || undefined}
aria-disabled={loading || undefined}
onClick={handleClick}
onPointerEnter={(event) => {
onPointerEnter?.(event);
fill(event.clientX, event.clientY);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
drain(event.clientX, event.clientY);
}}
onPointerDown={handlePointerDown}
onFocus={(event) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) fill();
}}
onBlur={(event) => {
onBlur?.(event);
if (!event.currentTarget.matches(":hover")) drain();
}}
className={`relative isolate inline-flex h-12 select-none items-center justify-center overflow-hidden px-7 text-sm font-medium tracking-tight transition-transform duration-150 active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--lb-accent)] disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress motion-reduce:active:scale-100 ${variantClass} ${className}`}
style={{ borderRadius: rounded, ["--lb-accent" as string]: accent, ["--lb-ink" as string]: inkColor, ...style }}
{...props}
>
{/* Liquid layers — purely decorative. */}
<span aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-hidden" style={{ borderRadius: "inherit" }}>
{layer(true)}
{layer(false)}
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-white/40 mix-blend-soft-light"
style={{ left: r.x, top: r.y, width: r.d, height: r.d, x: "-50%", y: "-50%" }}
initial={{ scale: 0, opacity: 0.9 }}
animate={{ scale: 1, opacity: 0 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
onAnimationComplete={() => setRipples((list) => list.filter((item) => item.id !== r.id))}
/>
))}
</span>
<span
className={`inline-flex items-center gap-2 transition-[color,opacity] ${loading ? "opacity-0" : "opacity-100"} ${showFill ? "text-[var(--lb-ink)]" : ""}`}
style={{
transitionDuration: reduceMotion ? "120ms" : `${Math.round(Math.max(duration, 0) * 0.45)}ms`,
transitionDelay: showFill && !reduceMotion ? `${Math.round(Math.max(duration, 0) * 0.18)}ms` : "0ms",
}}
>
{children}
</span>
{loading && (
<span className="absolute inset-0 flex items-center justify-center text-[var(--lb-ink)]">
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-5 animate-spin motion-reduce:animate-none" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.22" strokeWidth="2" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="sr-only">{loadingLabel}</span>
</span>
)}
</button>
);
});