Buttons
LiquidFlow
Colour floods in from the exact point the pointer enters and drains out where it leaves. Keyboard focus floods from the centre.
Download
- React
- Tailwind CSS v4
- motion
components/ui/liquid-flow.tsx
"use client";
import { useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type LiquidFlowProps = {
children: ReactNode;
className?: string;
onClick?: () => void;
/** Colour that floods the button. */
fill?: string;
/** Label colour while the button is filled. */
fillText?: string;
/** Seconds the flood takes. */
duration?: number;
};
/* LiquidFlow: colour floods in from the exact point the pointer enters, and drains out where it leaves. */
export function LiquidFlow({
children,
className = "rounded-full border border-[#a78bfa]/60 px-9 py-4 text-[15px] font-medium text-[#ede9fe]",
onClick,
fill = "#a78bfa",
fillText = "#1a1530",
duration = 0.5,
}: LiquidFlowProps) {
const reduce = useReducedMotion();
const [spot, setSpot] = useState({ x: 0, y: 0, r: 0, on: false });
const flood = (el: HTMLElement, x: number, y: number, on: boolean) => {
const { width: w, height: h } = el.getBoundingClientRect();
// Radius that reaches the farthest corner from (x, y).
const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
setSpot({ x, y, r, on });
};
const fromPointer = (e: React.PointerEvent<HTMLButtonElement>, on: boolean) => {
const b = e.currentTarget.getBoundingClientRect();
flood(e.currentTarget, e.clientX - b.left, e.clientY - b.top, on);
};
const fromCentre = (e: React.FocusEvent<HTMLButtonElement>, on: boolean) => {
const b = e.currentTarget.getBoundingClientRect();
flood(e.currentTarget, b.width / 2, b.height / 2, on);
};
return (
<button
type="button"
onClick={onClick}
onPointerEnter={(e) => fromPointer(e, true)}
onPointerLeave={(e) => fromPointer(e, false)}
onFocus={(e) => fromCentre(e, true)}
onBlur={(e) => fromCentre(e, false)}
className={`relative isolate overflow-hidden ${className}`}
>
<motion.span
aria-hidden
className="pointer-events-none absolute -z-10 rounded-full"
style={{ left: spot.x - spot.r, top: spot.y - spot.r, width: spot.r * 2, height: spot.r * 2, background: fill }}
initial={false}
animate={{ scale: spot.on ? 1 : 0 }}
transition={{ duration: reduce ? 0 : duration, ease: [0.22, 1, 0.36, 1] }}
/>
<span
className="relative transition-colors"
style={{ color: spot.on ? fillText : undefined, transitionDuration: `${reduce ? 0 : duration * 0.6}s` }}
>
{children}
</span>
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/liquid-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/liquid-flow-demo.tsx
import { LiquidFlow } from "@/components/ui/liquid-flow"; export default function LiquidFlowExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_60%,#1d1a33,#0d0c14_70%)]"> <LiquidFlow>Hover to flood</LiquidFlow> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Button label. |
| onClick | () => void | — | Click handler. |
| fill | string | "#a78bfa" | Colour that floods the button. |
| fillText | string | "#1a1530" | Label colour while filled. |
| duration | number | 0.5 | Seconds the flood takes. |
| className | string | violet outline pill | Replaces the default look. |