diff --git a/.changeset/six-clubs-grab.md b/.changeset/six-clubs-grab.md new file mode 100644 index 00000000..945de7d1 --- /dev/null +++ b/.changeset/six-clubs-grab.md @@ -0,0 +1,5 @@ +--- +"@shipfox/react-ui": minor +--- + +Add Dot-grid component diff --git a/libs/react/ui/package.json b/libs/react/ui/package.json index 75f69e96..09b5a27f 100644 --- a/libs/react/ui/package.json +++ b/libs/react/ui/package.json @@ -39,6 +39,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.24", + "gsap": "^3.13.0", "lucide-react": "^0.553.0", "react-day-picker": "^9.5.1", "recharts": "^3.1.0", @@ -75,16 +76,16 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.1.11", "@vitejs/plugin-react": "^5.0.4", + "@vitest/browser-playwright": "^4.0.8", + "@vitest/coverage-v8": "^4.0.8", "date-fns": "^4.1.0", "jsdom": "^27.0.0", + "playwright": "^1.56.1", "storybook": "^10.0.0", "storybook-addon-pseudo-states": "^10.0.0", "tailwindcss": "^4.1.13", "tw-animate-css": "^1.4.0", "vite": "^7.1.7", - "vitest": "^4.0.8", - "playwright": "^1.56.1", - "@vitest/browser-playwright": "^4.0.8", - "@vitest/coverage-v8": "^4.0.8" + "vitest": "^4.0.8" } } diff --git a/libs/react/ui/src/components/dot-grid/dot-grid.tsx b/libs/react/ui/src/components/dot-grid/dot-grid.tsx new file mode 100644 index 00000000..bb2cf2e8 --- /dev/null +++ b/libs/react/ui/src/components/dot-grid/dot-grid.tsx @@ -0,0 +1,325 @@ +import {gsap} from 'gsap'; +import {InertiaPlugin} from 'gsap/InertiaPlugin'; +import type React from 'react'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; +import {cn} from 'utils'; + +gsap.registerPlugin(InertiaPlugin); + +const HEX_COLOR_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; + +const throttle = void>(func: T, limit: number): T => { + let lastCall = 0; + return ((...args: Parameters) => { + const now = performance.now(); + if (now - lastCall >= limit) { + lastCall = now; + func(...args); + } + }) as T; +}; + +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; +} + +type RgbColor = { + r: number; + g: number; + b: number; +}; + +function hexToRgb(hex: string): RgbColor { + const m = hex.match(HEX_COLOR_REGEX); + 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), + }; +} + +export function 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, +}: DotGridProps): React.JSX.Element { + 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 colorGradient = useMemo(() => { + const gradient: string[] = new Array(256); + for (let i = 0; i < 256; i++) { + const normalizedSqDist = i / 255; + const normalizedDist = Math.sqrt(normalizedSqDist); + const t = 1 - normalizedDist; + 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); + gradient[i] = `rgb(${r},${g},${b})`; + } + return gradient; + }, [baseRgb, activeRgb]); + + 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 fillColor = baseColor; + if (dsq <= proxSq) { + const normalizedSqDist = dsq / proxSq; + const index = Math.min(255, Math.max(0, Math.round(normalizedSqDist * 255))); + fillColor = colorGradient[index]; + } + + ctx.save(); + ctx.translate(ox, oy); + ctx.fillStyle = fillColor; + ctx.fill(circlePath); + ctx.restore(); + } + + rafId = requestAnimationFrame(draw); + }; + + draw(); + return () => cancelAnimationFrame(rafId); + }, [proximity, baseColor, colorGradient, 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 canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.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 canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.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) as (e: MouseEvent) => void; + const wrapper = wrapperRef.current; + if (wrapper) { + wrapper.addEventListener('mousemove', throttledMove, {passive: true}); + wrapper.addEventListener('click', onClick); + } + return () => { + if (wrapper) { + wrapper.removeEventListener('mousemove', throttledMove); + wrapper.removeEventListener('click', onClick); + } + }; + }, [maxSpeed, speedTrigger, proximity, resistance, returnDuration, shockRadius, shockStrength]); + + return ( +
+
+ {/** biome-ignore lint/a11y/noAriaHiddenOnFocusable: */} +
+
+ ); +} diff --git a/libs/react/ui/src/components/dot-grid/index.ts b/libs/react/ui/src/components/dot-grid/index.ts new file mode 100644 index 00000000..3b11686d --- /dev/null +++ b/libs/react/ui/src/components/dot-grid/index.ts @@ -0,0 +1 @@ +export * from './dot-grid'; diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index 4c059123..80b4b9b9 100644 --- a/libs/react/ui/src/components/index.ts +++ b/libs/react/ui/src/components/index.ts @@ -4,6 +4,7 @@ export * from './badge'; export * from './button'; export * from './checkbox'; export * from './code-block'; +export * from './dot-grid'; export * from './dynamic-item'; export * from './icon'; export * from './inline-tips'; diff --git a/libs/react/ui/src/onboarding/sign-in.stories.tsx b/libs/react/ui/src/onboarding/sign-in.stories.tsx index 48cde2d9..364b6ab5 100644 --- a/libs/react/ui/src/onboarding/sign-in.stories.tsx +++ b/libs/react/ui/src/onboarding/sign-in.stories.tsx @@ -2,6 +2,7 @@ import {argosScreenshot} from '@argos-ci/storybook/vitest'; import type {Meta, StoryObj} from '@storybook/react'; import {Avatar} from 'components/avatar'; import {Button} from 'components/button'; +import {DotGrid} from 'components/dot-grid'; import {Header, Text} from 'components/typography'; const meta = { @@ -22,14 +23,25 @@ export const Default: Story = { return (
{/* Background illustration - simplified decorative element */} -
-
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 903f41fa..14b16458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: framer-motion: specifier: ^12.23.24 version: 12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + gsap: + specifier: ^3.13.0 + version: 3.13.0 lucide-react: specifier: ^0.553.0 version: 0.553.0(react@19.1.1) @@ -770,11 +773,13 @@ packages: '@biomejs/cli-darwin-arm64@2.3.5': resolution: {integrity: sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw==} engines: {node: '>=14.21.3'} + cpu: [arm64] os: [darwin] '@biomejs/cli-darwin-x64@2.3.5': resolution: {integrity: sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA==} engines: {node: '>=14.21.3'} + cpu: [x64] os: [darwin] '@biomejs/cli-linux-arm64-musl@2.3.5': @@ -786,6 +791,7 @@ packages: '@biomejs/cli-linux-arm64@2.3.5': resolution: {integrity: sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw==} engines: {node: '>=14.21.3'} + cpu: [arm64] os: [linux] '@biomejs/cli-linux-x64-musl@2.3.5': @@ -797,6 +803,7 @@ packages: '@biomejs/cli-linux-x64@2.3.5': resolution: {integrity: sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g==} engines: {node: '>=14.21.3'} + cpu: [x64] os: [linux] '@biomejs/cli-win32-arm64@2.3.5': @@ -3953,6 +3960,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-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -9206,6 +9216,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.13.0: {} + has-flag@4.0.0: {} hasown@2.0.2: