From 41907f4ac72733cabf3efa95114ce11fd2e47633 Mon Sep 17 00:00:00 2001 From: Seniru Date: Thu, 23 Oct 2025 14:45:37 +0530 Subject: [PATCH 1/2] Feat: hero section --- package.json | 1 + pnpm-lock.yaml | 8 + src/app/page.tsx | 8 +- src/components/Button.tsx | 35 +++++ src/components/DotGrid.tsx | 304 +++++++++++++++++++++++++++++++++++++ src/components/Hero.tsx | 44 ++++++ 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/components/Button.tsx create mode 100644 src/components/DotGrid.tsx create mode 100644 src/components/Hero.tsx 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..9fe2f70 --- /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. +

+
+
+
+ +
+ +
+
+
+ ); +} From 7a011f1780cd33e9a2421aeacc6043950bf47313 Mon Sep 17 00:00:00 2001 From: Seniru Date: Thu, 23 Oct 2025 14:54:31 +0530 Subject: [PATCH 2/2] Fix: hero section no full width --- src/components/Hero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 9fe2f70..080700c 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -11,7 +11,7 @@ export default function Hero() { dotSize={1} gap={40} /> -
+

SLIIT FOSS Community