Ink Field
A gooey ink blob chases the pointer across your headline and inverts it, stretching into a comma when you move fast and settling when you stop.
A free, copy-paste hover effects component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Move the pointer — the ink chases it with weight and lag
- Type under the ink is knocked out by a difference blend
- Circles fuse into one liquid shape through an SVG goo filter
Dependency
No dependencies — plain React. Just drop the file in.
Point the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/ink-field.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/ink-fieldHow to use
Wrap anything in Ink Field — a headline, a poster, a whole grid — and a gooey mass of ink chases the pointer across it. The body is a rope of circles — each one dragged to a fixed distance behind the one ahead — so the mass strings out into a long tapering tail that curves along the path you just took, then retracts into the head when you leave. Give it a height; the ink fills it.
Put the surface on the Ink Field itself, not on a wrapper around it — className="bg-white text-black" rather than a parent div. The ink can only invert what sits inside the field, so a background on the parent is outside the blend and the mass would read as a flat white shape instead of flipping to black.
The default invert mode paints the ink white and blends it with difference, so it reads black over light artwork and white over dark and knocks the type out either way — no theme branching, and it works over photography too. tint floods the mass in your own colour, and reveal uses the same moving shape as a mask over a second layer (pass it as the reveal prop) — a gradient-filled copy of the headline is the classic pairing.
Basic usage
import { InkField } from "@/components/harsh-ui/ink-field";
const Demo = () => (
// the surface goes on the field itself — the ink only inverts what's inside it
<InkField className="h-[24rem] w-full bg-[#f4f2ee] text-[#0b0b0c]">
<h2 className="flex h-full items-center px-8 text-6xl font-black uppercase leading-none">
Fluid system in constant field of interaction
</h2>
</InkField>
);Reveal a gradient layer
<InkField
mode="reveal"
reveal={<Headline className="bg-gradient-to-br from-rose-500 to-amber-400 bg-clip-text text-transparent" />}
>
<Headline />
</InkField>Heavier, wetter ink
// more circles + a lower lerp = a longer tail that lags further behind
<InkField blobs={22} size={120} gooeyness={16} viscosity={0.1} warp />Demo
import { InkFieldDemo } from "@/components/demos/ink-field-demo";
const Demo = () => (
<div className="h-screen w-full">
<InkFieldDemo />
</div>
);Props
Notes
- The tail is a distance-constrained rope, not a chain of lerps: every link is dragged to a fixed spacing behind the one ahead, so the mass keeps its length and curves along the path just travelled instead of collapsing into the head the moment you slow down. Spacing rides on the local radius, which tapers steeply, so the shape reads as a round head thinning to a point.
- The ink opens and closes. It grows in at the cursor and, when the pointer leaves — or you switch tab, or the window loses focus — it gathers the tail into the head and shrinks away instead of being stranded mid-stroke over the artwork. The loop deliberately runs past the last pointer event so that close can play out, then parks the moment nothing is on screen.
- A real pointer always outranks autoPlay: the moment you move, the drift stands down, so the lissajous can't fight the cursor for the target every frame — and leaving closes the ink rather than handing it back to the drift.
- The goo is an SVG filter pair: feGaussianBlur softens the alpha, then a steep feColorMatrix ramp (…0 0 0 20 -10) slams it back to a hard edge. Circles that share blurred alpha fuse into one organic shape — that's the whole liquid look, and it costs one filter rather than any per-pixel work.
- Inversion is a white mass under mix-blend-mode: difference, so it reads black over light artwork and white over dark and knocks type out in both themes from a single code path — no light/dark branching, and it works over photography too.
- The wrapper sets isolation: isolate. Without it the difference blend would invert the page behind the component, and any z-indexed layer in between would swallow the blend entirely. The flip side is that the ink can only invert what's inside the field — so the background belongs on the Ink Field, not on a parent wrapper.
- Filter and mask ids are built from useId(), so two Ink Fields on one page can't collide on the same #goo and render each other unfiltered.
- One rAF loop writes transforms straight to the circles — React never re-renders while the ink moves. The loop parks itself once the mass comes to rest and an IntersectionObserver stops it entirely when the field scrolls off-screen, so an idle field costs nothing.
- The visible ink and the reveal mask are the same live circles referenced through <use>, so the two can never drift out of sync.
- Pointer position is taken as a ratio of the box rather than raw pixels, so it survives the CSS-scaled grid-card preview; sizing reads clientWidth for the same reason.
- Falls back to a resting blob under prefers-reduced-motion, and autoPlay drifts the ink along a lissajous path wherever there's no pointer.
Source code
The complete ink-field.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* InkField
* A gooey mass of ink chases the pointer across any children and inverts them.
* The body is a rope of circles — each dragged a fixed distance behind the one
* ahead — fused by an SVG goo filter into one shape that strings out into a
* tapering tail, curves along the path it took, and closes when you leave.
* "invert" blends white ink with difference, so it reads black on light artwork
* and white on dark and knocks type out in either theme; "tint" floods a colour,
* "reveal" masks a second layer to the mass. Put the surface on the InkField
* itself, not a wrapper — the blend is isolated here, so it can only invert
* what's inside. One rAF loop writes transforms straight to the circles, so
* React never re-renders mid-motion. Zero dependencies — no Motion, no GSAP.
*/
export type InkFieldMode = "invert" | "tint" | "reveal";
export type InkFieldProps = {
children: ReactNode;
reveal?: ReactNode;
mode?: InkFieldMode;
blobs?: number;
size?: number;
gooeyness?: number;
viscosity?: number;
warp?: boolean;
color?: string;
autoPlay?: boolean;
className?: string;
};
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
export function InkField({
children,
reveal,
mode = "invert",
blobs = 24,
size = 150,
gooeyness = 10,
viscosity = 0.22,
warp = false,
color = "#ffffff",
autoPlay = false,
className = "",
}: InkFieldProps) {
const rootRef = useRef<HTMLDivElement>(null);
const dots = useRef<(SVGCircleElement | null)[]>([]);
const uid = useId().replace(/:/g, "");
const [gooId, maskId, srcId] = [`goo-${uid}`, `mask-${uid}`, `src-${uid}`];
const [reduce, setReduce] = useState(false);
const count = clamp(Math.round(blobs), 2, 32);
const radii = Array.from({ length: count }, (_, i) =>
Math.round(Math.max(1.5, size * 0.5 * (1 - i / (count - 1)) ** 2.3) * 100) / 100,
);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduce(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let w = root.clientWidth;
let h = root.clientHeight;
let rect = root.getBoundingClientRect();
const measure = () => {
w = root.clientWidth;
h = root.clientHeight;
rect = root.getBoundingClientRect();
};
const ro = new ResizeObserver(measure);
ro.observe(root);
window.addEventListener("scroll", measure, { passive: true, capture: true });
window.addEventListener("resize", measure, { passive: true });
const target = { x: w / 2, y: h / 2 };
const chain = Array.from({ length: count }, () => ({ ...target }));
const spacing = radii.map((r, i) => (i ? (radii[i - 1] + r) * 0.52 : 0));
const k = clamp(viscosity, 0.02, 1);
let visible = true;
let raf = 0;
let idle = 0;
let clock = 0;
let pointerDriven = false;
let open = autoPlay;
let presence = autoPlay ? 1 : 0;
const paint = () => {
const s = presence.toFixed(4);
for (let i = 0; i < count; i++) {
const p = chain[i];
dots.current[i]?.setAttribute(
"transform",
`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${s})`,
);
}
};
const step = () => {
raf = 0;
clock += 1 / 60;
if (autoPlay && !pointerDriven && open) {
target.x = w * (0.5 + 0.36 * Math.sin(clock * 1.7));
target.y = h * (0.5 + 0.3 * Math.sin(clock * 2.35 + 1.1));
}
const want = open ? 1 : 0;
presence += (want - presence) * (open ? 0.16 : 0.13);
if (Math.abs(want - presence) < 0.002) presence = want;
let moved = Math.abs(want - presence) * 60;
const head = chain[0];
const hdx = target.x - head.x;
const hdy = target.y - head.y;
head.x += hdx * k;
head.y += hdy * k;
moved = Math.max(moved, Math.abs(hdx) + Math.abs(hdy));
for (let i = 1; i < count; i++) {
const lead = chain[i - 1];
const p = chain[i];
const dx = lead.x - p.x;
const dy = lead.y - p.y;
const d = Math.hypot(dx, dy) || 1;
const gap = open ? spacing[i] : 0;
if (d <= gap) continue;
const pull = open ? (d - gap) / d : k;
p.x += dx * pull;
p.y += dy * pull;
moved = Math.max(moved, (d - gap) * 0.5);
}
paint();
if (!open && presence === 0) return;
idle = moved < 0.08 ? idle + 1 : 0;
if (visible && idle < 20) raf = requestAnimationFrame(step);
};
const start = () => {
idle = 0;
if (!raf && visible) raf = requestAnimationFrame(step);
};
const io = new IntersectionObserver(
([e]) => {
visible = e.isIntersecting;
if (visible) start();
},
{ threshold: 0 },
);
io.observe(root);
const onMove = (e: PointerEvent) => {
target.x = (rect.width ? (e.clientX - rect.left) / rect.width : 0.5) * w;
target.y = (rect.height ? (e.clientY - rect.top) / rect.height : 0.5) * h;
if (presence < 0.05) chain.forEach((p) => ((p.x = target.x), (p.y = target.y)));
pointerDriven = true;
open = true;
start();
};
const onLeave = () => {
if (!open && !pointerDriven) return;
pointerDriven = false;
open = false;
start();
};
const onVisibility = () => document.hidden && onLeave();
if (reduce) {
presence = 1;
chain.forEach((p) => ((p.x = w / 2), (p.y = h / 2)));
paint();
return () => ro.disconnect();
}
paint();
root.addEventListener("pointermove", onMove, { passive: true });
root.addEventListener("pointerleave", onLeave, { passive: true });
root.addEventListener("pointercancel", onLeave, { passive: true });
window.addEventListener("blur", onLeave);
document.addEventListener("visibilitychange", onVisibility);
if (autoPlay) start();
return () => {
ro.disconnect();
io.disconnect();
window.removeEventListener("scroll", measure, { capture: true });
window.removeEventListener("resize", measure);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointercancel", onLeave);
window.removeEventListener("blur", onLeave);
document.removeEventListener("visibilitychange", onVisibility);
if (raf) cancelAnimationFrame(raf);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, viscosity, autoPlay, reduce, size]);
return (
<div
ref={rootRef}
className={`relative overflow-hidden ${className}`}
style={{ isolation: "isolate" }}
>
<div className="relative z-0 h-full w-full">{children}</div>
<svg
aria-hidden
className="pointer-events-none absolute inset-0 z-10 h-full w-full"
style={{ mixBlendMode: mode === "invert" ? "difference" : "normal" }}
>
<defs>
<filter id={gooId} colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation={gooeyness} result="blur" />
<feColorMatrix
in="blur"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10"
result="goo"
/>
{warp && (
<>
<feTurbulence type="fractalNoise" baseFrequency="0.015" numOctaves={2} result="n" />
<feDisplacementMap in="goo" in2="n" scale={16} xChannelSelector="R" yChannelSelector="G" />
</>
)}
</filter>
<g id={srcId}>
{radii.map((r, i) => (
<circle key={i} ref={(el) => void (dots.current[i] = el)} cx={0} cy={0} r={r} />
))}
</g>
{mode === "reveal" && (
<mask id={maskId} maskUnits="userSpaceOnUse">
<g filter={`url(#${gooId})`}>
<use href={`#${srcId}`} fill="#ffffff" />
</g>
</mask>
)}
</defs>
{mode === "reveal" ? (
<foreignObject x={0} y={0} width="100%" height="100%" mask={`url(#${maskId})`}>
<div className="h-full w-full">{reveal}</div>
</foreignObject>
) : (
<g filter={`url(#${gooId})`}>
<use href={`#${srcId}`} fill={mode === "invert" ? "#ffffff" : color} />
</g>
)}
</svg>
</div>
);
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.