Ink Hover Reveal
A brush stroke of ink pours the colour photo in on hover.
Customize
<InkHover src={src} />Changes show live and stay in the address, so a link keeps them.
Code
"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>
);
}
The component. Installs to components/dimaac/ink-hover.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/ink-hover.jsonIt adds components/dimaac/ink-hover.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/ink-hover.
By hand
npm install gsap @gsap/react- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/ink-hover.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { InkHover } from "@/components/dimaac/ink-hover" and give its parent a size: it fills it.
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. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
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.txtFiles agents read
- llms.txt
- Every component and how to install it
- ink-hover.md
- This page as Markdown: install, props and the full source
- ink-hover.json
- The shadcn registry item
About
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.
- Installs to
components/dimaac/ink-hover.tsx- Uses
- gsap, @gsap/react
- Tags
- gsap, svg, hover, mask, image, card
Fashion portrait by West Kast on Lummi (Lummi License). Portrait by Amino on Lummi (Lummi License). Perfume bottle by Dona Mara on Lummi (Lummi License). Coastline by Cayetano Gros on Lummi (Lummi License). Cylinders by SHIHO on Lummi (Lummi License). Architecture by Viri Gutiérrez on Lummi (Lummi License).