Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"format:check": "prettier --check --cache \"**/*.{js,jsx,ts,tsx,md,mdx}\""
},
"dependencies": {
"gsap": "^3.13.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0"
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import Hero from '@/components/Hero';

export default function Home() {
return <div></div>;
return (
<main className="bg-white">
<Hero />
</main>
);
}
35 changes: 35 additions & 0 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MouseEventHandler, ReactNode, useMemo } from 'react';

interface ButtonProps {
type: 'primary' | 'secondary';
className?: string;
onClick?: MouseEventHandler;
children?: ReactNode;
}

export default function Button({
type = 'primary',
className,
onClick,
children,
...props
}: ButtonProps) {
const buttonTypeStyles = useMemo(() => {
switch (type) {
case 'primary':
return ' bg-black text-white';
case 'secondary':
return ' bg-[#EAEAEA] text-black border border-black';
}
}, [type]);

return (
<button
className={`cursor-pointer py-2 px-5 rounded-full ${buttonTypeStyles} ${className}`}
onClick={onClick}
{...props}
>
{children}
</button>
);
}
304 changes: 304 additions & 0 deletions src/components/DotGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
'use client';
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import { gsap } from 'gsap';
import { InertiaPlugin } from 'gsap/InertiaPlugin';

gsap.registerPlugin(InertiaPlugin);

const throttle = (func: (e: MouseEvent) => void, limit: number) => {
let lastCall = 0;
return function (this: unknown, e: MouseEvent) {
const now = performance.now();
if (now - lastCall >= limit) {
lastCall = now;
func.call(this, e);
}
};
};

interface Dot {
cx: number;
cy: number;
xOffset: number;
yOffset: number;
_inertiaApplied: boolean;
}

export interface DotGridProps {
dotSize?: number;
gap?: number;
baseColor?: string;
activeColor?: string;
proximity?: number;
speedTrigger?: number;
shockRadius?: number;
shockStrength?: number;
maxSpeed?: number;
resistance?: number;
returnDuration?: number;
className?: string;
style?: React.CSSProperties;
}

function hexToRgb(hex: string) {
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!m) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
};
}

const DotGrid: React.FC<DotGridProps> = ({
dotSize = 16,
gap = 32,
baseColor = '#5227FF',
activeColor = '#5227FF',
proximity = 150,
speedTrigger = 100,
shockRadius = 250,
shockStrength = 5,
maxSpeed = 5000,
resistance = 750,
returnDuration = 1.5,
className = '',
style,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const dotsRef = useRef<Dot[]>([]);
const pointerRef = useRef({
x: 0,
y: 0,
vx: 0,
vy: 0,
speed: 0,
lastTime: 0,
lastX: 0,
lastY: 0,
});

const baseRgb = useMemo(() => hexToRgb(baseColor), [baseColor]);
const activeRgb = useMemo(() => hexToRgb(activeColor), [activeColor]);

const circlePath = useMemo(() => {
if (typeof window === 'undefined' || !window.Path2D) return null;

const p = new Path2D();
p.arc(0, 0, dotSize / 2, 0, Math.PI * 2);
return p;
}, [dotSize]);

const buildGrid = useCallback(() => {
const wrap = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;

const { width, height } = wrap.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;

canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
if (ctx) ctx.scale(dpr, dpr);

const cols = Math.floor((width + gap) / (dotSize + gap));
const rows = Math.floor((height + gap) / (dotSize + gap));
const cell = dotSize + gap;

const gridW = cell * cols - gap;
const gridH = cell * rows - gap;

const extraX = width - gridW;
const extraY = height - gridH;

const startX = extraX / 2 + dotSize / 2;
const startY = extraY / 2 + dotSize / 2;

const dots: Dot[] = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const cx = startX + x * cell;
const cy = startY + y * cell;
dots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });
}
}
dotsRef.current = dots;
}, [dotSize, gap]);

useEffect(() => {
if (!circlePath) return;

let rafId: number;
const proxSq = proximity * proximity;

const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);

const { x: px, y: py } = pointerRef.current;

for (const dot of dotsRef.current) {
const ox = dot.cx + dot.xOffset;
const oy = dot.cy + dot.yOffset;
const dx = dot.cx - px;
const dy = dot.cy - py;
const dsq = dx * dx + dy * dy;

let style = baseColor;
if (dsq <= proxSq) {
const dist = Math.sqrt(dsq);
const t = 1 - dist / proximity;
const r = Math.round(baseRgb.r + (activeRgb.r - baseRgb.r) * t);
const g = Math.round(baseRgb.g + (activeRgb.g - baseRgb.g) * t);
const b = Math.round(baseRgb.b + (activeRgb.b - baseRgb.b) * t);
style = `rgb(${r},${g},${b})`;
}

ctx.save();
ctx.translate(ox, oy);
ctx.fillStyle = style;
ctx.fill(circlePath);
ctx.restore();
}

rafId = requestAnimationFrame(draw);
};

draw();
return () => cancelAnimationFrame(rafId);
}, [proximity, baseColor, activeRgb, baseRgb, circlePath]);

useEffect(() => {
buildGrid();
let ro: ResizeObserver | null = null;
if ('ResizeObserver' in window) {
ro = new ResizeObserver(buildGrid);
wrapperRef.current && ro.observe(wrapperRef.current);
} else {
(window as Window).addEventListener('resize', buildGrid);
}
return () => {
if (ro) ro.disconnect();
else window.removeEventListener('resize', buildGrid);
};
}, [buildGrid]);

useEffect(() => {
const onMove = (e: MouseEvent) => {
const now = performance.now();
const pr = pointerRef.current;
const dt = pr.lastTime ? now - pr.lastTime : 16;
const dx = e.clientX - pr.lastX;
const dy = e.clientY - pr.lastY;
let vx = (dx / dt) * 1000;
let vy = (dy / dt) * 1000;
let speed = Math.hypot(vx, vy);
if (speed > maxSpeed) {
const scale = maxSpeed / speed;
vx *= scale;
vy *= scale;
speed = maxSpeed;
}
pr.lastTime = now;
pr.lastX = e.clientX;
pr.lastY = e.clientY;
pr.vx = vx;
pr.vy = vy;
pr.speed = speed;

const rect = canvasRef.current!.getBoundingClientRect();
pr.x = e.clientX - rect.left;
pr.y = e.clientY - rect.top;

for (const dot of dotsRef.current) {
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);
if (speed > speedTrigger && dist < proximity && !dot._inertiaApplied) {
dot._inertiaApplied = true;
gsap.killTweensOf(dot);
const pushX = dot.cx - pr.x + vx * 0.005;
const pushY = dot.cy - pr.y + vy * 0.005;
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance },
onComplete: () => {
gsap.to(dot, {
xOffset: 0,
yOffset: 0,
duration: returnDuration,
ease: 'elastic.out(1,0.75)',
});
dot._inertiaApplied = false;
},
});
}
}
};

const onClick = (e: MouseEvent) => {
const rect = canvasRef.current!.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
for (const dot of dotsRef.current) {
const dist = Math.hypot(dot.cx - cx, dot.cy - cy);
if (dist < shockRadius && !dot._inertiaApplied) {
dot._inertiaApplied = true;
gsap.killTweensOf(dot);
const falloff = Math.max(0, 1 - dist / shockRadius);
const pushX = (dot.cx - cx) * shockStrength * falloff;
const pushY = (dot.cy - cy) * shockStrength * falloff;
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance },
onComplete: () => {
gsap.to(dot, {
xOffset: 0,
yOffset: 0,
duration: returnDuration,
ease: 'elastic.out(1,0.75)',
});
dot._inertiaApplied = false;
},
});
}
}
};

const throttledMove = throttle(onMove, 50);
window.addEventListener('mousemove', throttledMove, { passive: true });
window.addEventListener('click', onClick);

return () => {
window.removeEventListener('mousemove', throttledMove);
window.removeEventListener('click', onClick);
};
}, [
maxSpeed,
speedTrigger,
proximity,
resistance,
returnDuration,
shockRadius,
shockStrength,
]);

return (
<section
className={`p-4 flex items-center justify-center h-full w-full relative ${className}`}
style={style}
>
<div ref={wrapperRef} className="w-full h-full relative">
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
/>
</div>
</section>
);
};

export default DotGrid;
Loading