# Ink Fill Button

> An outline button that floods with colour from exactly where the cursor came in, and drains out towards where it leaves.

Category: Buttons · Free · Updated 2026-10-30 · Live demo: https://www.motionkitui.com/components/ink-button

## When to use it

Portfolio and agency sites, editorial pages and secondary calls to action, where a hover state can show some craft.

## How it works

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.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/ink-button.json
```

Or copy `components/motionkit/ink-button.tsx` below. Requires react and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| 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. |

## Accessibility

- **Keyboard: Pass.** A native <button>; keyboard focus fills it from the centre, so focus is unmistakable.
- **Screen readers: Pass.** Announced with its label; the ink is decoration.
- **Reduced motion: Pass.** With reduced motion on, the colour changes instantly instead of spreading.
- **Colour contrast: Pass.** Ivory text measures 17:1 at rest and dark text on the ivory ink 17:1 when filled.

## Source

```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>
  );
}
```
