Kanso UI LogoKanso UI
Components/halftone grid
effects

Halftone Grid

An interactive, mouse-responsive dithered background grid that modulates dot sizes dynamically based on the cursor's proximity.

gridbackgrounddotsripplehoverinteractive

Preview

Interactive Ripple

Move your mouse over the background to see the dot matrix grid expand and warp dynamically.

Adjust Halftone Grid Props
Color Theme:
Dot Spacing14px
Dot Radius1.5px
Bulge Strength67px
Cursor Radius500px
Enable Multi-Color Particles
Sparkling Effect

🖲Usage

example-usage.tsx
import { HalftoneGrid } from "@/components/kanso/halftone-grid"

export default function HalftoneGridDemo() {
  return (
    <div className="w-full h-[300px] border border-zinc-200 dark:border-zinc-800 rounded-xl relative overflow-hidden bg-zinc-950">
      <HalftoneGrid
        dotRadius={1.5}
        dotSpacing={12}
        gradientFrom="rgba(168, 85, 247, 0.35)"
        gradientTo="rgba(180, 151, 207, 0.25)"
        glowColor="#120F17"
        sparkle={true}
      />
    </div>
  )
}

Installation

1

Create folder & copy source

Create a folder named kanso inside your project's components directory (i.e. components/kanso/). Copy the source code shown in the next section, and paste it into a file named halftone-grid.tsx inside it.

2

Required helper files

Ensure your project has the following helper files configured:

  • lib/utils

<>Source Code

halftone-grid.tsx
'use client';

import * as React from 'react';
import { cn } from '@/lib/utils';

const TWO_PI = Math.PI * 2;

interface Dot {
  ax: number;
  ay: number;
  sx: number;
  sy: number;
  vx: number;
  vy: number;
  x: number;
  y: number;
}

export interface HalftoneGridProps extends React.HTMLAttributes<HTMLDivElement> {
  dotRadius?: number;
  dotSpacing?: number;
  cursorRadius?: number;
  cursorForce?: number;
  bulgeOnly?: boolean;
  bulgeStrength?: number;
  glowRadius?: number;
  sparkle?: boolean;
  waveAmplitude?: number;
  gradientFrom?: string;
  gradientTo?: string;
  glowColor?: string;
  colors?: string[];
}

const HalftoneGrid = React.memo(
  React.forwardRef<HTMLDivElement, HalftoneGridProps>(
    (
      {
        dotRadius = 1.5,
        dotSpacing = 14,
        cursorRadius = 500,
        cursorForce = 0.1,
        bulgeOnly = true,
        bulgeStrength = 67,
        glowRadius = 160,
        sparkle = false,
        waveAmplitude = 0,
        gradientFrom = 'rgba(168, 85, 247, 0.35)',
        gradientTo = 'rgba(180, 151, 207, 0.25)',
        glowColor = '#120F17',
        colors,
        className,
        style,
        ...props
      },
      ref
    ) => {
      const canvasRef = React.useRef<HTMLCanvasElement>(null);
      const svgRef = React.useRef<SVGSVGElement>(null);
      const glowRef = React.useRef<SVGCircleElement>(null);
      const dotsRef = React.useRef<Dot[]>([]);

      const [isDarkMode, setIsDarkMode] = React.useState(true);

      React.useEffect(() => {
        if (typeof window === 'undefined') return;
        const observer = new MutationObserver(() => {
          setIsDarkMode(document.documentElement.classList.contains('dark'));
        });

        setIsDarkMode(document.documentElement.classList.contains('dark'));

        observer.observe(document.documentElement, {
          attributes: true,
          attributeFilter: ['class'],
        });

        return () => observer.disconnect();
      }, []);

      const resolvedGlowColor = React.useMemo(() => {
        if (isDarkMode) {
          return glowColor;
        }
        const lowerGlow = glowColor.toLowerCase();
        if (lowerGlow.startsWith('#120f17')) return 'rgba(168, 85, 247, 0.12)';
        if (lowerGlow.startsWith('#081e24')) return 'rgba(6, 182, 212, 0.12)';
        if (lowerGlow.startsWith('#051f14')) return 'rgba(16, 185, 129, 0.12)';
        if (lowerGlow.startsWith('#240509')) return 'rgba(244, 63, 94, 0.12)';
        if (lowerGlow.startsWith('#241805')) return 'rgba(245, 158, 11, 0.12)';
        return glowColor;
      }, [isDarkMode, glowColor]);
      const mouseRef = React.useRef({
        x: -9999,
        y: -9999,
        prevX: -9999,
        prevY: -9999,
        speed: 0,
      });
      const rafRef = React.useRef<number | null>(null);
      const sizeRef = React.useRef({ w: 0, h: 0, offsetX: 0, offsetY: 0 });
      const glowOpacity = React.useRef(0);
      const engagement = React.useRef(0);

      const propsRef = React.useRef<Record<string, unknown>>({});
      propsRef.current = {
        dotRadius,
        dotSpacing,
        cursorRadius,
        cursorForce,
        bulgeOnly,
        bulgeStrength,
        sparkle,
        waveAmplitude,
        gradientFrom,
        gradientTo,
        colors,
      };

      const rebuildRef = React.useRef<(() => void) | null>(null);

      // Use React's useId to generate a stable, client-safe unique ID for the glow radial gradient
      const uniqueId = React.useId();
      const glowId = `halftone-glow-${uniqueId.replace(/:/g, '')}`;

      React.useEffect(() => {
        const canvas = canvasRef.current;
        const glowEl = glowRef.current;
        if (!canvas) return;

        const ctx = canvas.getContext('2d', { alpha: true });
        if (!ctx) return;

        const dpr = Math.min(window.devicePixelRatio || 1, 2);

        function buildDots(w: number, h: number) {
          const p = propsRef.current;
          const step = (p.dotRadius as number) + (p.dotSpacing as number);
          const cols = Math.floor(w / step);
          const rows = Math.floor(h / step);
          const padX = (w % step) / 2;
          const padY = (h % step) / 2;
          const dots: Dot[] = new Array(rows * cols);
          let idx = 0;

          for (let row = 0; row < rows; row++) {
            for (let col = 0; col < cols; col++) {
              const ax = padX + col * step + step / 2;
              const ay = padY + row * step + step / 2;
              dots[idx++] = {
                ax,
                ay,
                sx: ax,
                sy: ay,
                vx: 0,
                vy: 0,
                x: ax,
                y: ay,
              };
            }
          }
          dotsRef.current = dots;
        }

        function onMouseMove(e: MouseEvent) {
          const s = sizeRef.current;
          mouseRef.current.x = e.pageX - s.offsetX;
          mouseRef.current.y = e.pageY - s.offsetY;
        }

        function updateMouseSpeed() {
          const m = mouseRef.current;
          const dx = m.prevX - m.x;
          const dy = m.prevY - m.y;
          const dist = Math.sqrt(dx * dx + dy * dy);
          m.speed += (dist - m.speed) * 0.5;
          if (m.speed < 0.001) m.speed = 0;
          m.prevX = m.x;
          m.prevY = m.y;
        }

        const speedInterval = setInterval(updateMouseSpeed, 20);
        let frameCount = 0;

        function tick() {
          frameCount++;
          const dots = dotsRef.current;
          const m = mouseRef.current;
          const { w, h } = sizeRef.current;
          const p = propsRef.current;
          const len = dots.length;
          const t = frameCount * 0.02;

          const targetEngagement = Math.min(m.speed / 5, 1);
          engagement.current += (targetEngagement - engagement.current) * 0.06;
          if (engagement.current < 0.001) engagement.current = 0;
          const eng = engagement.current;

          glowOpacity.current += (eng - glowOpacity.current) * 0.08;

          if (glowEl) {
            glowEl.setAttribute('cx', String(m.x));
            glowEl.setAttribute('cy', String(m.y));
            glowEl.style.opacity = String(glowOpacity.current);
          }

          ctx!.clearRect(0, 0, w, h);

          const cr = p.cursorRadius as number;
          const crSq = cr * cr;
          const rad = (p.dotRadius as number) / 2;
          const isBulge = p.bulgeOnly as boolean;

          // 1. Run physics updates for all dots first
          for (let i = 0; i < len; i++) {
            const d = dots[i];
            const dx = m.x - d.ax;
            const dy = m.y - d.ay;
            const distSq = dx * dx + dy * dy;

            if (distSq < crSq && eng > 0.01) {
              const dist = Math.sqrt(distSq);
              if (isBulge) {
                const termVal = 1 - dist / cr;
                const push =
                  termVal * termVal * (p.bulgeStrength as number) * eng;
                const angle = Math.atan2(dy, dx);
                d.sx += (d.ax - Math.cos(angle) * push - d.sx) * 0.15;
                d.sy += (d.ay - Math.sin(angle) * push - d.sy) * 0.15;
              } else {
                const angle = Math.atan2(dy, dx);
                const move =
                  (500 / dist) * (m.speed * (p.cursorForce as number));
                d.vx += Math.cos(angle) * -move;
                d.vy += Math.sin(angle) * -move;
              }
            } else if (isBulge) {
              d.sx += (d.ax - d.sx) * 0.1;
              d.sy += (d.ay - d.sy) * 0.1;
            }

            if (!isBulge) {
              d.vx *= 0.9;
              d.vy *= 0.9;
              d.x = d.ax + d.vx;
              d.y = d.ay + d.vy;
              d.sx += (d.x - d.sx) * 0.1;
              d.sy += (d.y - d.sy) * 0.1;
            }
          }

          // 2. Draw dots (partitioned by color if colors array exists)
          const getDotDrawCoords = (i: number, d: Dot) => {
            let drawX = d.sx;
            let drawY = d.sy;
            if ((p.waveAmplitude as number) > 0) {
              drawY += Math.sin(d.ax * 0.03 + t) * (p.waveAmplitude as number);
              drawX +=
                Math.cos(d.ay * 0.03 + t * 0.7) *
                (p.waveAmplitude as number) *
                0.5;
            }
            return { drawX, drawY };
          };

          const cols = p.colors as string[];
          if (cols && cols.length > 0) {
            for (let cIdx = 0; cIdx < cols.length; cIdx++) {
              ctx!.fillStyle = cols[cIdx];
              ctx!.beginPath();
              for (let i = cIdx; i < len; i += cols.length) {
                const d = dots[i];
                const { drawX, drawY } = getDotDrawCoords(i, d);
                if (p.sparkle) {
                  const hash = ((i * 2654435761) ^ (frameCount >> 3)) >>> 0;
                  if (hash % 100 < 3) {
                    ctx!.moveTo(drawX + rad * 1.8, drawY);
                    ctx!.arc(drawX, drawY, rad * 1.8, 0, TWO_PI);
                  } else {
                    ctx!.moveTo(drawX + rad, drawY);
                    ctx!.arc(drawX, drawY, rad, 0, TWO_PI);
                  }
                } else {
                  ctx!.moveTo(drawX + rad, drawY);
                  ctx!.arc(drawX, drawY, rad, 0, TWO_PI);
                }
              }
              ctx!.fill();
            }
          } else {
            const grad = ctx!.createLinearGradient(0, 0, w, h);
            grad.addColorStop(0, p.gradientFrom as string);
            grad.addColorStop(1, p.gradientTo as string);
            ctx!.fillStyle = grad;

            ctx!.beginPath();
            for (let i = 0; i < len; i++) {
              const d = dots[i];
              const { drawX, drawY } = getDotDrawCoords(i, d);
              if (p.sparkle) {
                const hash = ((i * 2654435761) ^ (frameCount >> 3)) >>> 0;
                if (hash % 100 < 3) {
                  ctx!.moveTo(drawX + rad * 1.8, drawY);
                  ctx!.arc(drawX, drawY, rad * 1.8, 0, TWO_PI);
                } else {
                  ctx!.moveTo(drawX + rad, drawY);
                  ctx!.arc(drawX, drawY, rad, 0, TWO_PI);
                }
              } else {
                ctx!.moveTo(drawX + rad, drawY);
                ctx!.arc(drawX, drawY, rad, 0, TWO_PI);
              }
            }
            ctx!.fill();
          }

          rafRef.current = requestAnimationFrame(tick);
        }

        // Setup ResizeObserver to observe size changes dynamically
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            const { width: w, height: h } = entry.contentRect;
            if (w > 0 && h > 0) {
              if (!canvas) return;
              canvas.width = w * dpr;
              canvas.height = h * dpr;
              canvas.style.width = `${w}px`;
              canvas.style.height = `${h}px`;
              ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);

              const rect = canvas.getBoundingClientRect();
              sizeRef.current = {
                w,
                h,
                offsetX: rect.left + window.scrollX,
                offsetY: rect.top + window.scrollY,
              };

              buildDots(w, h);
            }
          }
        });

        const parent = canvas.parentElement;
        if (parent) {
          resizeObserver.observe(parent);
        }

        window.addEventListener('mousemove', onMouseMove, { passive: true });
        rafRef.current = requestAnimationFrame(tick);

        rebuildRef.current = () => {
          const { w, h } = sizeRef.current;
          if (w > 0 && h > 0) buildDots(w, h);
        };

        return () => {
          resizeObserver.disconnect();
          if (rafRef.current) cancelAnimationFrame(rafRef.current);
          clearInterval(speedInterval);
          window.removeEventListener('mousemove', onMouseMove);
        };
      }, []);

      React.useEffect(() => {
        rebuildRef.current?.();
      }, [dotRadius, dotSpacing]);

      return (
        <div
          ref={ref}
          className={cn('w-full h-full relative overflow-hidden', className)}
          style={style}
          {...props}
        >
          <canvas
            ref={canvasRef}
            className="absolute inset-0 block w-full h-full pointer-events-none"
          />
          <svg
            ref={svgRef}
            className="absolute inset-0 block w-full h-full pointer-events-none"
          >
            <defs>
              <radialGradient id={glowId}>
                <stop offset="0%" stopColor={resolvedGlowColor} />
                <stop
                  offset="100%"
                  stopColor={resolvedGlowColor}
                  stopOpacity={0}
                />
              </radialGradient>
            </defs>
            <circle
              ref={glowRef}
              cx="-9999"
              cy="-9999"
              r={glowRadius}
              fill={`url(#${glowId})`}
              style={{ opacity: 0, willChange: 'opacity' }}
            />
          </svg>
        </div>
      );
    }
  )
);

HalftoneGrid.displayName = 'HalftoneGrid';

export { HalftoneGrid };

Props

Command Palette

Search for a command to run...