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 (
+
- Your AI‑powered second brain. Capture voice, images, and - text—Reflecto turns it into a searchable, connected knowledge base. -
-- Transform scattered thoughts into organized knowledge in four - seamless steps. -
-- Start free. Your knowledge stays private. -
-+ Your AI‑powered second brain. Capture voice, images, and + text—Reflecto turns it into a searchable, connected knowledge + base. +
+