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