From bd2d6ebdfcf80e3d2d027f926fe9ec2fb9d66fde Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Wed, 3 Sep 2025 23:23:14 -0300 Subject: [PATCH] feat: add DotGrid component for enhanced visual effects on the homepage --- apps/web/package.json | 1 + apps/web/src/components/DotGrid.css | 22 +++ apps/web/src/components/DotGrid.jsx | 268 ++++++++++++++++++++++++++++ apps/web/src/routes/index.tsx | 253 ++++++++++++++------------ pnpm-lock.yaml | 8 + 5 files changed, 437 insertions(+), 115 deletions(-) create mode 100644 apps/web/src/components/DotGrid.css create mode 100644 apps/web/src/components/DotGrid.jsx diff --git a/apps/web/package.json b/apps/web/package.json index 17fb5d5..1e32a48 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "appwrite": "^14.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "gsap": "^3.13.0", "lucide-react": "^0.473.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.2", diff --git a/apps/web/src/components/DotGrid.css b/apps/web/src/components/DotGrid.css new file mode 100644 index 0000000..dac88cb --- /dev/null +++ b/apps/web/src/components/DotGrid.css @@ -0,0 +1,22 @@ +.dot-grid { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + position: relative; +} + +.dot-grid__wrap { + width: 100%; + height: 100%; + position: relative; +} + +.dot-grid__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/apps/web/src/components/DotGrid.jsx b/apps/web/src/components/DotGrid.jsx new file mode 100644 index 0000000..211f506 --- /dev/null +++ b/apps/web/src/components/DotGrid.jsx @@ -0,0 +1,268 @@ +'use client'; +import { useRef, useEffect, useCallback, useMemo } from 'react'; +import { gsap } from 'gsap'; +import { InertiaPlugin } from 'gsap/InertiaPlugin'; + +import './DotGrid.css'; + +gsap.registerPlugin(InertiaPlugin); + +const throttle = (func, limit) => { + let lastCall = 0; + return function (...args) { + const now = performance.now(); + if (now - lastCall >= limit) { + lastCall = now; + func.apply(this, args); + } + }; +}; + +function hexToRgb(hex) { + 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 = ({ + 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 window.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 = []; + 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; + 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 = null; + if ('ResizeObserver' in window) { + ro = new ResizeObserver(buildGrid); + wrapperRef.current && ro.observe(wrapperRef.current); + } else { + window.addEventListener('resize', buildGrid); + } + return () => { + if (ro) ro.disconnect(); + else window.removeEventListener('resize', buildGrid); + }; + }, [buildGrid]); + + useEffect(() => { + const onMove = e => { + 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 => { + 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/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4d7bcb0..e72e66b 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -10,6 +10,7 @@ import { Sparkles, } from "lucide-react"; import type { ComponentType } from "react"; +import DotGrid from "@/components/DotGrid"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { trpc } from "@/utils/trpc"; @@ -264,129 +265,151 @@ const FEATURES: Feature[] = [ function HomeComponent() { return ( -
- {/* Hero */} -
-
- -

- Reflecto -

-

- Your AI‑powered second brain. Capture voice, images, and - text—Reflecto turns it into a searchable, connected knowledge base. -

-
- - -
-
- Reflecto - Private by default • Powered by lightweight AI -
-
- {/* Showcase preview */} -
- -
-
- - {/* Features */} -
- {/* quick stats */} -
- {[ - { n: "2x", l: "Faster capture" }, - { n: "100%", l: "Private by default" }, - { n: "< 1s", l: "Search latency" }, - { n: "∞", l: "Ideas connected" }, - ].map((s) => ( -
-
{s.n}
-
{s.l}
-
- ))} -
-
- {FEATURES.map((f) => ( - - ))} -
-
- - {/* How it works */} -
-
-
- - How it works -
-

- From chaos to clarity -

-

- Transform scattered thoughts into organized knowledge in four - seamless steps. -

-
- -
- - {/* Call to action */} -
-
-
-
-

- Build your second brain -

-

- Start free. Your knowledge stays private. -

-
-
+ <> +
+ +
+
+ {/* Hero */} +
+
+ +

+ Reflecto +

+

+ Your AI‑powered second brain. Capture voice, images, and + text—Reflecto turns it into a searchable, connected knowledge + base. +

+
+
+ Reflecto + Private by default • Powered by lightweight AI +
-
-
+ {/* Showcase preview */} +
+ +
+ - {/* Footer */} -
-
-
- Reflecto - Reflecto + {/* Features */} +
+ {/* quick stats */} +
+ {[ + { n: "2x", l: "Faster capture" }, + { n: "100%", l: "Private by default" }, + { n: "< 1s", l: "Search latency" }, + { n: "∞", l: "Ideas connected" }, + ].map((s) => ( +
+
{s.n}
+
{s.l}
+
+ ))}
-
- Home - Dashboard +
+ {FEATURES.map((f) => ( + + ))}
-
-
-
+ + + {/* How it works */} +
+
+
+ + How it works +
+

+ From chaos to clarity +

+

+ Transform scattered thoughts into organized knowledge in four + seamless steps. +

+
+ +
+ + {/* Call to action */} +
+
+
+
+

+ Build your second brain +

+

+ Start free. Your knowledge stays private. +

+
+
+ + +
+
+
+
+ + {/* Footer */} + + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1c5c4..9703fab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + gsap: + specifier: ^3.13.0 + version: 3.13.0 lucide-react: specifier: ^0.473.0 version: 0.473.0(react@19.1.1) @@ -3198,6 +3201,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.13.0: + resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -7606,6 +7612,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.13.0: {} + has-bigints@1.1.0: {} has-property-descriptors@1.0.2: