An outline button that floods with colour from exactly where the cursor came in, and drains out towards where it leaves.
Customize
Usage
import { InkButton } from "@/components/motionkit/ink-button";
<InkButton>View the work</InkButton>Motion
The exact curves this component runs, plotted with Motion's own maths.
Ink spread
Pointer enters or leaves
Portfolio and agency sites, editorial pages and secondary calls to action, where a hover state can show some craft.
Behind the label sits a circle twice the button's diagonal, so wherever it's centred it covers the whole button. When the pointer enters, the circle jumps to that exact point at zero scale, then grows on the next frame; text colour switches as it spreads. When the pointer leaves, the full circle moves its centre to the exit point, which is invisible because it still covers everything, and then shrinks away towards it. Keyboard focus fills it from the centre.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://www.motionkitui.com/r/ink-button.jsonnpm install motionSave as components/motionkit/ink-button.tsx.
"use client";
import { useRef, type ComponentProps, type PointerEvent } from "react";
type InkButtonProps = ComponentProps<"button"> & {
/** Colour that floods in. */
ink?: string;
/** Text colour once flooded. */
inkText?: string;
};
/**
* An outline button that floods with colour from exactly where the cursor came in, and drains out towards
* where it leaves. Keyboard focus fills it from the centre.
*/
export function InkButton({ ink = "#F6F1E8", inkText = "#0C1010", className = "", children, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: InkButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const blot = useRef<HTMLSpanElement>(null);
// the blot is a circle as wide as the button's diagonal, so wherever it's centred it covers the whole button
function place(x: number, y: number, filled: boolean) {
const button = ref.current;
const element = blot.current;
if (!button || !element) return;
const size = Math.hypot(button.offsetWidth, button.offsetHeight) * 2;
element.style.width = element.style.height = `${size}px`;
element.style.left = `${x - size / 2}px`;
element.style.top = `${y - size / 2}px`;
element.style.transform = `scale(${filled ? 1 : 0})`;
button.style.color = filled ? inkText : "";
}
const at = (event: PointerEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top] as const;
};
return (
<button
{...props}
ref={ref}
onPointerEnter={(event) => {
onPointerEnter?.(event);
const [x, y] = at(event);
// jump to the entry point while still empty, then grow on the next frame
if (blot.current) blot.current.style.transition = "none";
place(x, y, false);
requestAnimationFrame(() => {
if (blot.current) blot.current.style.transition = "";
place(x, y, true);
});
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
const [x, y] = at(event);
// still full, so moving the centre to the exit point is invisible; then it shrinks away towards it
if (blot.current) blot.current.style.transition = "none";
place(x, y, true);
requestAnimationFrame(() => {
if (blot.current) blot.current.style.transition = "";
place(x, y, false);
});
}}
onFocus={(event) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) place(event.currentTarget.offsetWidth / 2, event.currentTarget.offsetHeight / 2, true);
}}
onBlur={(event) => {
onBlur?.(event);
place(event.currentTarget.offsetWidth / 2, event.currentTarget.offsetHeight / 2, false);
}}
className={`relative isolate inline-flex items-center justify-center gap-2 overflow-hidden rounded-full border px-6 py-2.5 text-sm font-medium text-[#F6F1E8] transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] disabled:pointer-events-none disabled:opacity-50 ${className}`}
style={{ borderColor: ink }}
>
<span
ref={blot}
aria-hidden
className="pointer-events-none absolute -z-10 rounded-full transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:duration-0"
style={{ backgroundColor: ink, transform: "scale(0)" }}
/>
{children}
</button>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| ink | string | "#F6F1E8" | Colour that floods in (also the outline). |
| inkText | string | "#0C1010" | Text colour once filled. |
| ...props | ButtonHTMLAttributes | — | Anything a <button> accepts. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://www.motionkitui.com/r/ink-button.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the colour changes instantly instead of spreading.
Magnetic Button
A button that leans toward the cursor with a springy, physical pull.
Outline Fill Text
A heading drawn as an outline that floods with solid colour from left to right, on hover or as it scrolls into view.
Arrow Slide Button
A call to action whose arrow slides out on hover while a fresh one slides in from the left. Pure CSS, solid or outline.