-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): add DotGrid component with GSAP animations #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@shipfox/react-ui": minor | ||
| --- | ||
|
|
||
| Add Dot-grid component |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 = <T extends (...args: never[]) => void>(func: T, limit: number): T => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let lastCall = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ((...args: Parameters<T>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const now = performance.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (now - lastCall >= limit) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastCall = now; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| func(...args); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) as T; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+20
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+52
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hexToRgb silently treats non‑6‑digit hex (like
Recommend normalizing 3‑digit hex to 6‑digit before matching, so both forms work: function hexToRgb(hex: string): RgbColor {
- const m = hex.match(HEX_COLOR_REGEX);
+ let value = hex.trim();
+
+ // Expand 3-digit #rgb to 6-digit #rrggbb for convenience.
+ if (value.length === 4 && value[0] === '#') {
+ value = `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`;
+ }
+
+ const m = value.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),
};
}This keeps the existing regex and behavior while making the API friendlier to callers. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<HTMLDivElement>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const canvasRef = useRef<HTMLCanvasElement>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dotsRef = useRef<Dot[]>([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.clearRect(0, 0, canvas.width / (window.devicePixelRatio || 1), canvas.height / (window.devicePixelRatio || 1)); |
Copilot
AI
Nov 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The velocity calculation divides by dt which could be very small or even zero on the first call (when pr.lastTime is 0), potentially resulting in extremely large or infinite velocity values. While the maxSpeed clamping helps, it's safer to add a guard:
const dt = pr.lastTime ? Math.max(now - pr.lastTime, 1) : 16;This ensures dt is never too small.
| const dt = pr.lastTime ? now - pr.lastTime : 16; | |
| const dt = pr.lastTime ? Math.max(now - pr.lastTime, 1) : 16; |
kylengn marked this conversation as resolved.
Show resolved
Hide resolved
Copilot
AI
Nov 18, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate code: The inertia animation and return-to-origin logic is duplicated between the mouse move handler (lines 237-248) and click handler (lines 267-278). Consider extracting this into a reusable function to improve maintainability.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './dot-grid'; |
Uh oh!
There was an error while loading. Please reload this page.