---
title: Ink Hover Reveal
description: A brush stroke of ink pours the colour photo in on hover.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/ink-hover
registry: https://ui.dimaac.com/r/ink-hover.json
---

# Ink Hover Reveal

A greyscale photo card. When the cursor lands on it, a thick SVG stroke draws across it like wet ink, widening as it goes, and the colour photo shows through the stroke until the card is full. Leaving plays the same timeline backwards so the colour drains out along the same line. Pick one of three brush paths (wave, zigzag, scribble), the stroke thickness, the speed and the ink colour. Each card carries its own SVG mask (unique ids per instance), taps open and close it on touch screens, keyboard focus opens it too, and reduced motion swaps the brush for a short fade. Rebuilt from the svg-hover YouTube demo, with the colour revealed through the stroke instead of painted over the photo.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/ink-hover.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/ink-hover.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/ink-hover.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/ink-hover`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/ink-hover.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { InkHover } from "@/components/dimaac/ink-hover" and give its parent a size: it fills it.

## Usage

```tsx
import { InkHover } from "@/components/dimaac/ink-hover";

<InkHover src={src} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` | - | The photo. It shows in greyscale until the ink pours in. A 4:5 portrait fits best. |
| `hoverSrc` | `string` | - | A different photo to pour in. Leave it empty to pour in the colour version of `src`. |
| `alt` | `string` | `""` | Alt text for the photo. Leave it empty if the label already says what it is. |
| `label` | `string` | - | Title shown on the card once the colour is in. |
| `caption` | `string` | - | A short line under the title. |
| `strokeWidth` | `number` | `140` | How thick the brush stroke is while it draws, in pixels of a 800 wide card. The stroke then widens until it floods the card. |
| `duration` | `number` | `0.9` | How long the brush takes to cross the card, in seconds. |
| `path` | `InkHoverPath` | `"wave"` | The shape of the brush path. |
| `grayscaleBase` | `boolean` | `true` | Show the base photo in greyscale. Turn it off to see the full colour photo under the ink. |
| `inkColor` | `string` | `"#111111"` | The colour of the wet ink that leads the stroke. |
| `className` | `string` | `""` | Extra classes for the card. It is a 4:5 box by default: give it your own size or aspect. |

## Credits

- Fashion portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Perfume bottle: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Coastline: Cayetano Gros, Lummi (https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7), Lummi License
- Cylinders: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Architecture: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License

## Source

### ink-hover.tsx → `components/dimaac/ink-hover.tsx`

```tsx
"use client";

import { useId, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A greyscale photo that fills with colour when you hover it. A thick brush stroke draws across
// the card like wet ink, and the colour photo shows through the stroke as it widens, until the
// whole card is poured in. Leaving runs the same timeline backwards, so the colour drains away
// along the same line. Everything lives in one SVG, so the photo and the brush always line up.

/** The three brush paths. Each one is drawn in a 800 x 1000 box and runs from outside the card to outside it. */
const PATHS = {
  // Soft S-bends, row after row down the card
  wave: "M -60 130 C 120 20 260 240 420 130 S 720 20 860 130 C 960 200 700 300 520 300 S 160 270 -60 330 C -110 350 60 480 220 480 S 560 380 860 480 C 950 520 760 650 560 650 S 200 570 -60 650 C -110 680 100 800 300 800 S 620 700 860 810 C 950 850 780 940 560 940 S 220 890 -60 950",
  // Hard diagonal passes, like a marker filling a page
  zigzag: "M -40 60 L 840 220 L -40 380 L 840 540 L -40 700 L 840 860 L 120 1060",
  // A loose, looping scribble
  scribble: "M 90 940 C -40 640 380 740 300 520 S 700 700 640 400 S 130 460 240 220 S 720 280 700 60 C 690 -40 520 -20 560 120 S 640 420 420 600 S 100 780 200 950 S 520 1010 640 880 S 860 700 760 560",
} as const;

export type InkHoverPath = keyof typeof PATHS;

export type InkHoverProps = {
  /** The photo. It shows in greyscale until the ink pours in. A 4:5 portrait fits best. */
  src: string;
  /** A different photo to pour in. Leave it empty to pour in the colour version of `src`. */
  hoverSrc?: string;
  /** Alt text for the photo. Leave it empty if the label already says what it is. */
  alt?: string;
  /** Title shown on the card once the colour is in. */
  label?: string;
  /** A short line under the title. */
  caption?: string;
  /** How thick the brush stroke is while it draws, in pixels of a 800 wide card. The stroke then widens until it floods the card. */
  strokeWidth?: number;
  /** How long the brush takes to cross the card, in seconds. */
  duration?: number;
  /** The shape of the brush path. */
  path?: InkHoverPath;
  /** Show the base photo in greyscale. Turn it off to see the full colour photo under the ink. */
  grayscaleBase?: boolean;
  /** The colour of the wet ink that leads the stroke. */
  inkColor?: string;
  /** Extra classes for the card. It is a 4:5 box by default: give it your own size or aspect. */
  className?: string;
};

// How wide the stroke ends up, so the last pass floods every gap between the rows
const FLOOD_WIDTH = 780;
// The path is normalised to length 1 (pathLength), so the dash tricks work for any shape.
// Starting a little past 1 keeps the round cap of the empty stroke out of the card.
const HIDDEN_OFFSET = 1.12;

export function InkHover({
  src,
  hoverSrc,
  alt = "",
  label,
  caption,
  strokeWidth = 140,
  duration = 0.9,
  path = "wave",
  grayscaleBase = true,
  inkColor = "#111111",
  className = "",
}: InkHoverProps) {
  const cardRef = useRef<HTMLButtonElement>(null);
  const timelineRef = useRef<gsap.core.Timeline | null>(null);
  const pointerTypeRef = useRef("mouse");
  const openRef = useRef(false);

  // useId gives each card its own ids, so two cards never share a mask or a filter
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const maskId = `ink-mask-${uid}`;
  const greyId = `ink-grey-${uid}`;
  const d = PATHS[path];
  const revealSrc = hoverSrc || src;

  useGSAP(
    () => {
      const card = cardRef.current;
      if (!card) return;
      const inkLine = card.querySelector<SVGPathElement>("[data-ink]");
      const maskLine = card.querySelector<SVGPathElement>("[data-mask]");
      const reveal = card.querySelector<SVGGElement>("[data-reveal]");
      const revealImage = card.querySelector<SVGImageElement>("[data-reveal-image]");
      const words = card.querySelector<HTMLElement>("[data-words]");
      if (!inkLine || !maskLine || !reveal || !revealImage || !words) return;

      const mm = gsap.matchMedia();

      mm.add(
        { calm: "(prefers-reduced-motion: reduce)", lively: "(prefers-reduced-motion: no-preference)" },
        (context) => {
          const calm = Boolean(context.conditions?.calm);
          const timeline = gsap.timeline({ paused: true, defaults: { ease: "power2.out" } });
          timelineRef.current = timeline;

          if (calm) {
            // Reduced motion: no brush. The mask is fully open and the colour photo simply fades in.
            gsap.set(maskLine, { attr: { "stroke-dashoffset": 0, "stroke-width": FLOOD_WIDTH } });
            gsap.set(inkLine, { opacity: 0 });
            gsap.set(reveal, { opacity: 0 });
            gsap.set(words, { opacity: 0 });
            timeline.to(reveal, { opacity: 1, duration: 0.3 }, 0).to(words, { opacity: 1, duration: 0.3 }, 0);
            return;
          }

          // The ink is a little thinner than the colour, so it only shows as a dark tip at the
          // front of the stroke and a thin rim along its edge, never as blobs between the passes
          const inkScale = 0.8;
          gsap.set(maskLine, { attr: { "stroke-dashoffset": HIDDEN_OFFSET, "stroke-width": strokeWidth } });
          gsap.set(inkLine, { attr: { "stroke-dashoffset": HIDDEN_OFFSET, "stroke-width": strokeWidth * inkScale } });
          gsap.set(revealImage, { scale: 1.12, transformOrigin: "50% 50%" });
          gsap.set(words, { opacity: 0, y: 14 });

          // The ink goes first, the colour photo follows a beat behind
          const lag = duration * 0.07;
          [
            { line: inkLine, at: 0, width: FLOOD_WIDTH * inkScale },
            { line: maskLine, at: lag, width: FLOOD_WIDTH },
          ].forEach(({ line, at, width }) => {
            timeline.to(line, { attr: { "stroke-dashoffset": 0 }, duration, ease: "power2.out" }, at);
            // The stroke thickens while it is still travelling, which is what makes it feel poured
            timeline.to(line, { attr: { "stroke-width": width }, duration: duration * 0.8, ease: "power2.inOut" }, at + duration * 0.25);
          });
          timeline.to(revealImage, { scale: 1, duration: duration * 1.2, ease: "power2.out" }, lag);
          timeline.to(words, { opacity: 1, y: 0, duration: 0.5 }, duration * 0.55);
        },
      );

      const play = () => {
        openRef.current = true;
        card.setAttribute("aria-pressed", "true");
        timelineRef.current?.timeScale(1).play();
      };
      // Leaving is a touch faster than arriving
      const stop = () => {
        openRef.current = false;
        card.setAttribute("aria-pressed", "false");
        timelineRef.current?.timeScale(1.3).reverse();
      };

      const onPointerEnter = (event: PointerEvent) => {
        if (event.pointerType !== "touch") play();
      };
      const onPointerLeave = (event: PointerEvent) => {
        if (event.pointerType !== "touch") stop();
      };
      const onPointerDown = (event: PointerEvent) => {
        pointerTypeRef.current = event.pointerType;
      };
      // Touch has no hover, so a tap opens the card and the next tap closes it
      const onClick = () => {
        if (pointerTypeRef.current !== "touch") return;
        if (openRef.current) stop();
        else play();
      };
      // Keyboard users get the same reveal when the card takes focus
      const onFocus = () => {
        if (card.matches(":focus-visible")) play();
      };
      const onBlur = () => {
        if (openRef.current && pointerTypeRef.current !== "touch") stop();
      };

      card.addEventListener("pointerenter", onPointerEnter);
      card.addEventListener("pointerleave", onPointerLeave);
      card.addEventListener("pointerdown", onPointerDown);
      card.addEventListener("click", onClick);
      card.addEventListener("focus", onFocus);
      card.addEventListener("blur", onBlur);

      return () => {
        card.removeEventListener("pointerenter", onPointerEnter);
        card.removeEventListener("pointerleave", onPointerLeave);
        card.removeEventListener("pointerdown", onPointerDown);
        card.removeEventListener("click", onClick);
        card.removeEventListener("focus", onFocus);
        card.removeEventListener("blur", onBlur);
        timelineRef.current?.kill();
        timelineRef.current = null;
        openRef.current = false;
        card.setAttribute("aria-pressed", "false");
        mm.revert();
      };
    },
    { scope: cardRef, dependencies: [strokeWidth, duration, path, src, revealSrc], revertOnUpdate: true },
  );

  const accessibleName = [label, caption].filter(Boolean).join(", ") || alt || undefined;

  return (
    <button
      ref={cardRef}
      type="button"
      aria-pressed="false"
      aria-label={accessibleName}
      className={`group relative block aspect-[4/5] w-full cursor-pointer overflow-hidden rounded-xl border-0 bg-neutral-200 p-0 text-left outline-offset-4 focus-visible:outline-2 focus-visible:outline-current dark:bg-neutral-800 ${className}`}
      style={{ touchAction: "manipulation", WebkitTapHighlightColor: "transparent" }}
    >
      <svg viewBox="0 0 800 1000" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 h-full w-full" aria-hidden="true" focusable="false">
        <defs>
          <filter id={greyId} colorInterpolationFilters="sRGB">
            <feColorMatrix type="saturate" values="0" />
          </filter>
          {/* The brush is white on a clear mask, so the colour photo shows only where the brush has been */}
          <mask id={maskId} maskUnits="userSpaceOnUse" x="-400" y="-400" width="1600" height="1800">
            <path data-mask d={d} pathLength={1} fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} strokeDasharray="1 2" strokeDashoffset={HIDDEN_OFFSET} />
          </mask>
        </defs>
        <image href={src} x="0" y="0" width="800" height="1000" preserveAspectRatio="xMidYMid slice" filter={grayscaleBase ? `url(#${greyId})` : undefined} />
        <path data-ink d={d} pathLength={1} fill="none" stroke={inkColor} strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} strokeDasharray="1 2" strokeDashoffset={HIDDEN_OFFSET} />
        <g data-reveal mask={`url(#${maskId})`}>
          <image data-reveal-image href={revealSrc} x="0" y="0" width="800" height="1000" preserveAspectRatio="xMidYMid slice" />
        </g>
      </svg>

      {(label || caption) && (
        <span data-words className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-gradient-to-t from-black/60 to-transparent px-5 pt-14 pb-4 text-white opacity-0" aria-hidden="true">
          {label && <span className="text-[17px] leading-tight font-medium tracking-[-0.01em]">{label}</span>}
          {caption && <span className="text-[13px] opacity-80">{caption}</span>}
        </span>
      )}
    </button>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { InkHover, type InkHoverProps } from "@/components/dimaac/ink-hover";

// Six Lummi photos (Lummi License: free for personal and commercial use)
const cards = [
  { src: "fashion/orange-visor-4x5.jpg", label: "Orange visor", caption: "Studio, teal and amber", alt: "Woman in an orange visor and navy dress against a teal background" },
  { src: "objects/amber-perfume-4x5.jpg", label: "Amber glass", caption: "Leaf shadows, green wall", alt: "Amber perfume bottle against a green background with leaf shadows" },
  { src: "portraits/lime-blazer-4x5.jpg", label: "Lime blazer", caption: "Pink, loud and sure", alt: "Person with curly red hair in a lime green blazer against a pink background" },
  { src: "nature/coastline-aerial-4x5.jpg", label: "Turquoise edge", caption: "Where the cliffs stop", alt: "Aerial view of turquoise waves crashing on a sandy beach below cliffs" },
  { src: "art/colour-cylinders-4x5.jpg", label: "Colour blocks", caption: "Orange, red, blue, green", alt: "Colourful cylinders on a bold geometric background" },
  { src: "architecture/pastel-rooftop-4x5.jpg", label: "Pastel roofline", caption: "Purple wall, pink house", alt: "Pastel architecture with a purple wall and a pink building under a blue sky" },
];

export default function Demo(props: Partial<InkHoverProps>) {
  return (
    <div className="relative flex h-full w-full flex-col items-center justify-center gap-5 overflow-hidden bg-[#f3f0eb] px-5 py-6 text-[#1c1917] dark:bg-[#0f0d0c] dark:text-[#e8ddd4]">
      <header className="flex w-full max-w-[1100px] items-end justify-between gap-4" style={{ width: "min(100%, calc(1.2 * (100vh - 190px) + 32px))" }}>
        <h1 className="text-[clamp(22px,3.4vw,40px)] leading-none font-medium tracking-[-0.035em]">Colour, poured in</h1>
        <p className="font-mono text-[11px] tracking-wide uppercase opacity-60">Hover or tap a card</p>
      </header>

      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4" style={{ width: "min(100%, calc(1.2 * (100vh - 190px) + 32px))" }}>
        {cards.map((card) => (
          <InkHover key={card.src} {...props} src={media(card.src)} alt={card.alt} label={card.label} caption={card.caption} />
        ))}
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Ink Hover Reveal component (<InkHover />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/ink-hover
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/ink-hover.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/ink-hover.json

Then use it like this:
import { InkHover } from "@/components/dimaac/ink-hover";
<InkHover src={src} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/ink-hover.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
