feat: add DotGrid component for enhanced visual effects on the homepage

This commit is contained in:
2025-09-03 23:23:14 -03:00
parent 5519751da5
commit bd2d6ebdfc
5 changed files with 437 additions and 115 deletions

View File

@@ -26,6 +26,7 @@
"appwrite": "^14.0.1", "appwrite": "^14.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gsap": "^3.13.0",
"lucide-react": "^0.473.0", "lucide-react": "^0.473.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.2", "radix-ui": "^1.4.2",

View File

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

View File

@@ -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 (
<section className={`dot-grid ${className}`} style={style}>
<div ref={wrapperRef} className="dot-grid__wrap">
<canvas ref={canvasRef} className="dot-grid__canvas" />
</div>
</section>
);
};
export default DotGrid;

View File

@@ -10,6 +10,7 @@ import {
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import DotGrid from "@/components/DotGrid";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
@@ -264,6 +265,26 @@ const FEATURES: Feature[] = [
function HomeComponent() { function HomeComponent() {
return ( return (
<>
<div
style={{
width: "100%",
height: "100%",
position: "fixed",
}}
>
<DotGrid
activeColor="#525151"
baseColor="#281E37"
dotSize={5}
gap={15}
proximity={120}
resistance={750}
returnDuration={1.5}
shockRadius={250}
shockStrength={5}
/>
</div>
<div className="relative"> <div className="relative">
{/* Hero */} {/* Hero */}
<section className="container mx-auto max-w-7xl px-4 pt-14 pb-24 sm:pt-20 sm:pb-32"> <section className="container mx-auto max-w-7xl px-4 pt-14 pb-24 sm:pt-20 sm:pb-32">
@@ -274,7 +295,8 @@ function HomeComponent() {
</h1> </h1>
<p className="mt-5 text-balance text-lg text-muted-foreground sm:text-xl"> <p className="mt-5 text-balance text-lg text-muted-foreground sm:text-xl">
Your AIpowered second brain. Capture voice, images, and Your AIpowered second brain. Capture voice, images, and
textReflecto turns it into a searchable, connected knowledge base. textReflecto turns it into a searchable, connected knowledge
base.
</p> </p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3"> <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button asChild size="lg"> <Button asChild size="lg">
@@ -388,5 +410,6 @@ function HomeComponent() {
</div> </div>
</footer> </footer>
</div> </div>
</>
); );
} }

8
pnpm-lock.yaml generated
View File

@@ -105,6 +105,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
gsap:
specifier: ^3.13.0
version: 3.13.0
lucide-react: lucide-react:
specifier: ^0.473.0 specifier: ^0.473.0
version: 0.473.0(react@19.1.1) version: 0.473.0(react@19.1.1)
@@ -3198,6 +3201,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 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: has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -7606,6 +7612,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
gsap@3.13.0: {}
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
has-property-descriptors@1.0.2: has-property-descriptors@1.0.2: