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 };