effects#fluid#ink
Fluid Ink Cursor
Cursor leaves realistic ink trails that disperse and blend like watercolors with velocity-based splatter
Implementation Guide
# Fluid Ink Cursor Effect
> A painterly cursor effect where mouse movement leaves realistic ink trails that disperse and blend like watercolors. Drops expand, drift, and fade with an organic, artistic feel.
## Quick Start
```tsx
import FluidInkCursor from '@/components/examples/effects/fluid-ink-cursor';
export default function Hero() {
return (
<div className="relative h-screen">
<FluidInkCursor colors={['#007AFF', '#5856D6']} />
<div className="relative z-10">{/* Your content goes here */}</div>
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| ----------- | -------- | -------------------------------------------- | ----------------------------------------------- |
| colors | string[] | ['#007AFF', '#5856D6', '#FF3B30', '#FF9500'] | Array of hex colors for ink drops |
| inkSize | number | 30 | Base size of ink drops in pixels |
| fadeSpeed | number | 0.015 | How quickly drops fade (0.01 - 0.05) |
| blendAmount | number | 12 | Blur amount for metaball blending effect (4-20) |
## Full Implementation
```tsx
'use client';
import { useEffect, useRef, useCallback } from 'react';
interface FluidInkCursorProps {
colors?: string[];
inkSize?: number;
fadeSpeed?: number;
blendAmount?: number;
}
interface InkDrop {
x: number;
y: number;
radius: number;
maxRadius: number;
opacity: number;
color: string;
vx: number;
vy: number;
}
export default function FluidInkCursor({
colors = ['#007AFF', '#5856D6', '#FF3B30', '#FF9500'],
inkSize = 30,
fadeSpeed = 0.015,
blendAmount = 12,
}: FluidInkCursorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const tempCanvasRef = useRef<HTMLCanvasElement | null>(null);
const dropsRef = useRef<InkDrop[]>([]);
const mouseRef = useRef<{ x: number; y: number; lastX: number; lastY: number }>({
x: 0,
y: 0,
lastX: 0,
lastY: 0,
});
const animationFrameRef = useRef<number>(0);
const prefersReducedMotionRef = useRef<boolean>(false);
const colorIndexRef = useRef<number>(0);
const lastSpawnTimeRef = useRef<number>(0);
const hexToRgba = useCallback((hex: string, alpha: number): string => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return `rgba(0, 122, 255, ${alpha})`;
}, []);
const spawnDrop = useCallback(
(x: number, y: number, velocity: number) => {
const color = colors[colorIndexRef.current % colors.length];
colorIndexRef.current++;
// Velocity affects size: faster movement = more ink
const velocityScale = Math.min(velocity / 50, 2);
const baseRadius = inkSize * 0.3;
const maxRadius = inkSize * (0.6 + velocityScale * 0.4);
dropsRef.current.push({
x,
y,
radius: baseRadius,
maxRadius,
opacity: 0.9,
color,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5 + 0.2, // Slight downward drift
});
// Limit total drops for performance
if (dropsRef.current.length > 100) {
dropsRef.current = dropsRef.current.slice(-80);
}
},
[colors, inkSize]
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return;
// Create temporary canvas for the gooey effect
const tempCanvas = document.createElement('canvas');
tempCanvasRef.current = tempCanvas;
const tempContext = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempContext) return;
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mediaQuery.matches;
const handleMotionPreferenceChange = (event: MediaQueryListEvent) => {
prefersReducedMotionRef.current = event.matches;
};
mediaQuery.addEventListener('change', handleMotionPreferenceChange);
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
tempCanvas.width = rect.width * dpr;
tempCanvas.height = rect.height * dpr;
context.scale(dpr, dpr);
tempContext.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
const handleMouseMove = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const currentX = event.clientX - rect.left;
const currentY = event.clientY - rect.top;
mouseRef.current.lastX = mouseRef.current.x;
mouseRef.current.lastY = mouseRef.current.y;
mouseRef.current.x = currentX;
mouseRef.current.y = currentY;
if (prefersReducedMotionRef.current) return;
const dx = currentX - mouseRef.current.lastX;
const dy = currentY - mouseRef.current.lastY;
const velocity = Math.sqrt(dx * dx + dy * dy);
// Spawn drops based on velocity (faster = more drops)
const now = Date.now();
const spawnInterval = Math.max(20, 80 - velocity * 2);
if (velocity > 2 && now - lastSpawnTimeRef.current > spawnInterval) {
spawnDrop(currentX, currentY, velocity);
lastSpawnTimeRef.current = now;
}
};
const handleTouchMove = (event: TouchEvent) => {
if (event.touches.length === 0) return;
const touch = event.touches[0];
const rect = canvas.getBoundingClientRect();
const currentX = touch.clientX - rect.left;
const currentY = touch.clientY - rect.top;
const dx = currentX - mouseRef.current.x;
const dy = currentY - mouseRef.current.y;
const velocity = Math.sqrt(dx * dx + dy * dy);
mouseRef.current.lastX = mouseRef.current.x;
mouseRef.current.lastY = mouseRef.current.y;
mouseRef.current.x = currentX;
mouseRef.current.y = currentY;
if (prefersReducedMotionRef.current) return;
const now = Date.now();
const spawnInterval = Math.max(20, 80 - velocity * 2);
if (velocity > 2 && now - lastSpawnTimeRef.current > spawnInterval) {
spawnDrop(currentX, currentY, velocity);
lastSpawnTimeRef.current = now;
}
};
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('touchmove', handleTouchMove, { passive: true });
const animate = () => {
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// Clear temp canvas
tempContext.fillStyle = '#0a0a0a';
tempContext.fillRect(0, 0, width, height);
const drops = dropsRef.current;
const isReducedMotion = prefersReducedMotionRef.current;
// Update and draw drops on temp canvas
for (let i = drops.length - 1; i >= 0; i--) {
const drop = drops[i];
if (!isReducedMotion) {
// Expand radius toward max
if (drop.radius < drop.maxRadius) {
drop.radius += (drop.maxRadius - drop.radius) * 0.08;
}
// Apply drift
drop.x += drop.vx;
drop.y += drop.vy;
// Slow down drift
drop.vx *= 0.98;
drop.vy *= 0.98;
// Fade out
drop.opacity -= fadeSpeed;
} else {
// Static display for reduced motion
drop.radius = drop.maxRadius;
drop.opacity -= fadeSpeed * 0.5;
}
// Remove faded drops
if (drop.opacity <= 0) {
drops.splice(i, 1);
continue;
}
// Draw drop with gradient for watercolor effect
const gradient = tempContext.createRadialGradient(
drop.x,
drop.y,
0,
drop.x,
drop.y,
drop.radius
);
gradient.addColorStop(0, hexToRgba(drop.color, drop.opacity));
gradient.addColorStop(0.5, hexToRgba(drop.color, drop.opacity * 0.7));
gradient.addColorStop(1, hexToRgba(drop.color, 0));
tempContext.beginPath();
tempContext.arc(drop.x, drop.y, drop.radius, 0, Math.PI * 2);
tempContext.fillStyle = gradient;
tempContext.fill();
}
// Apply gooey/metaball effect using blur and threshold
// This creates the ink blending effect
context.fillStyle = '#0a0a0a';
context.fillRect(0, 0, width, height);
// Apply blur filter for the gooey effect
context.filter = `blur(${blendAmount}px) contrast(20) brightness(1.1)`;
context.drawImage(tempCanvas, 0, 0, width, height);
context.filter = 'none';
// Draw the original drops on top for crisp colors
context.globalCompositeOperation = 'source-over';
for (const drop of drops) {
const gradient = context.createRadialGradient(
drop.x,
drop.y,
0,
drop.x,
drop.y,
drop.radius * 0.7
);
gradient.addColorStop(0, hexToRgba(drop.color, drop.opacity * 0.6));
gradient.addColorStop(1, hexToRgba(drop.color, 0));
context.beginPath();
context.arc(drop.x, drop.y, drop.radius * 0.7, 0, Math.PI * 2);
context.fillStyle = gradient;
context.fill();
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(animationFrameRef.current);
window.removeEventListener('resize', resizeCanvas);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('touchmove', handleTouchMove);
mediaQuery.removeEventListener('change', handleMotionPreferenceChange);
};
}, [colors, inkSize, fadeSpeed, blendAmount, spawnDrop, hexToRgba]);
return (
<div
className="relative h-full w-full"
style={{ background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)' }}
>
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="font-mono text-xs tracking-widest text-white/30 uppercase">
Move cursor to paint
</span>
</div>
</div>
);
}
```
## Customization
### Brand Colors
Use your brand palette for a cohesive look:
```tsx
<FluidInkCursor colors={['#FF3B30', '#FF9500']} />
```
### Monochrome Ink
Single-color ink for a classic look:
```tsx
<FluidInkCursor colors={['#FFFFFF']} inkSize={25} />
```
### Watercolor Blend
Soft, painterly effect with more blending:
```tsx
<FluidInkCursor colors={['#007AFF', '#5856D6', '#AF52DE']} blendAmount={18} fadeSpeed={0.01} />
```
### Bold Splatter
High-energy effect with larger, faster-fading drops:
```tsx
<FluidInkCursor colors={['#FF3B30', '#FF9500', '#FFCC00']} inkSize={50} fadeSpeed={0.025} />
```
### Subtle Background
Gentle effect for backgrounds with content:
```tsx
<FluidInkCursor colors={['#5856D6', '#007AFF']} inkSize={20} fadeSpeed={0.02} blendAmount={8} />
```
### Rainbow Trail
Full spectrum color cycling:
```tsx
<FluidInkCursor
colors={['#FF3B30', '#FF9500', '#FFCC00', '#34C759', '#007AFF', '#5856D6', '#AF52DE']}
inkSize={25}
/>
```
## Performance Considerations
- **Drop limit**: The effect automatically limits drops to 100 maximum, removing oldest drops first
- **Blur filter**: The `blendAmount` prop affects GPU usage - reduce on lower-end devices
- **Mobile**: Touch events are supported with passive listeners for smooth scrolling
- **Canvas scaling**: Automatically handles devicePixelRatio for crisp rendering on retina displays
- **Spawn throttling**: Drops spawn at intervals based on velocity to prevent overwhelming the canvas
### Mobile Optimization
```tsx
import { useEffect, useState } from 'react';
function ResponsiveFluidInk() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return (
<FluidInkCursor
inkSize={isMobile ? 20 : 30}
blendAmount={isMobile ? 8 : 12}
fadeSpeed={isMobile ? 0.02 : 0.015}
/>
);
}
```
### Reduced Motion Alternative
The effect respects `prefers-reduced-motion`. When enabled:
- No new drops spawn from cursor movement
- Existing drops immediately reach full size
- Drops fade at half speed for a static, gentle display
## Accessibility
- Respects `prefers-reduced-motion` - animations are significantly reduced
- Canvas has `aria-hidden="true"` as it is purely decorative
- Touch support for mobile devices with passive event listeners
- Hint text is displayed for discoverability but uses `pointer-events-none`
- Content layered above should maintain proper contrast ratios
## Browser Support
- Modern browsers with Canvas 2D support
- CSS `filter` property support required (Chrome 53+, Firefox 35+, Safari 9.1+)
- requestAnimationFrame support required
- MediaQueryList.addEventListener support (Chrome 39+, Firefox 55+, Safari 14+)
- Works in all modern browsers including Chrome, Firefox, Safari, and Edge