diff --git a/package.json b/package.json
index 2311767..dbfabc4 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eae9ebd..9fb13cb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ gsap:
+ specifier: ^3.13.0
+ version: 3.13.0
next:
specifier: 15.5.4
version: 15.5.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1297,6 +1300,9 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+ gsap@3.13.0:
+ resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==}
+
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3623,6 +3629,8 @@ snapshots:
graphemer@1.4.0: {}
+ gsap@3.13.0: {}
+
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7bcd29e..13b2cba 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,9 @@
+import Hero from '@/components/Hero';
+
export default function Home() {
- return
;
+ return (
+
+
+
+ );
}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 0000000..40b17db
--- /dev/null
+++ b/src/components/Button.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/DotGrid.tsx b/src/components/DotGrid.tsx
new file mode 100644
index 0000000..4b94786
--- /dev/null
+++ b/src/components/DotGrid.tsx
@@ -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 = ({
+ 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(null);
+ const canvasRef = useRef(null);
+ const dotsRef = useRef([]);
+ 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 (
+
+ );
+};
+
+export default DotGrid;
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
new file mode 100644
index 0000000..080700c
--- /dev/null
+++ b/src/components/Hero.tsx
@@ -0,0 +1,44 @@
+import Button from './Button';
+import DotGrid from './DotGrid';
+
+export default function Hero() {
+ return (
+
+
+
+
+ SLIIT FOSS Community
+
+
+
+ A group of volunteers who believe in the usage of Free & Open Source
+ Software (FOSS).
+
+
+ The primary objective of the community is to promote, develop and
+ diversify the usage of Free and Open Source Software at SLIIT.
+
+
+
+
+
+
+
+
+
+
+ );
+}