From 7d3cc1fb26221a7e867475b48a2355b0761fce9c Mon Sep 17 00:00:00 2001
From: Andres Estrada <105224908+andesvel@users.noreply.github.com>
Date: Tue, 22 Sep 2026 23:46:17 -0500
Subject: [PATCH 1/6] Render the grid backdrop across the whole landing page
The grid div was absolutely positioned against the initial
containing block, so it spanned exactly one viewport from the top of
the document: scrolling past the hero left the rest of the page on a
plain background. A fixed viewport layer covers every screen for the
entire scroll and skips scroll-driven repaints.
---
app/routes/home.tsx | 3 ++-
components/grid-background.tsx | 18 ++++++++++++++++++
2 files changed, 20 insertions(+), 1 deletion(-)
create mode 100644 components/grid-background.tsx
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 3c82eb3..9d8d793 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -7,6 +7,7 @@ import { Features } from "@/components/features";
// import { Separator } from "@/components/ui/separator";
import { NameOrigin } from "@/components/name-origin";
import { CtaSection } from "@/components/cta-section";
+import { GridBackground } from "@/components/grid-background";
// eslint-disable-next-line no-empty-pattern
export function meta({}: Route.MetaArgs) {
@@ -43,7 +44,7 @@ export default function Home() {
-
+
);
}
diff --git a/components/grid-background.tsx b/components/grid-background.tsx
new file mode 100644
index 0000000..e33100a
--- /dev/null
+++ b/components/grid-background.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+
+/**
+ * Full-viewport decorative grid backdrop.
+ *
+ * Rendered `fixed` so it covers the whole page during scroll and never
+ * repaints for it. Pure decoration: hidden from a11y tree, ignores pointer
+ * events, and stays perfectly still unless motion is requested and allowed
+ * (see the parallax effect for the enhanced behavior).
+ */
+export function GridBackground() {
+ return (
+
+ );
+}
From ec0abb017438831da5dce79f678ad5c11a43f802 Mon Sep 17 00:00:00 2001
From: Andres Estrada <105224908+andesvel@users.noreply.github.com>
Date: Tue, 22 Sep 2026 23:47:14 -0500
Subject: [PATCH 2/6] Drift the grid opposite the cursor for parallax depth
A fine pointer with no reduced-motion preference gets a subtle
background-position drift (max 10px) away from the cursor, lerped in
an rAF loop that stops once the pattern settles so idle sessions cost
no frames. Touch devices and reduced-motion users get the static
grid from the previous commit.
---
components/grid-background.tsx | 88 ++++++++++++++++++++++++++++++++--
1 file changed, 85 insertions(+), 3 deletions(-)
diff --git a/components/grid-background.tsx b/components/grid-background.tsx
index e33100a..3eb8367 100644
--- a/components/grid-background.tsx
+++ b/components/grid-background.tsx
@@ -1,16 +1,98 @@
import * as React from "react";
+const MAX_SHIFT = 10;
+const LERP = 0.08;
+const SETTLE = 0.05;
+
/**
* Full-viewport decorative grid backdrop.
*
* Rendered `fixed` so it covers the whole page during scroll and never
- * repaints for it. Pure decoration: hidden from a11y tree, ignores pointer
- * events, and stays perfectly still unless motion is requested and allowed
- * (see the parallax effect for the enhanced behavior).
+ * repaints for it. Pure decoration: hidden from a11y tree and ignores pointer
+ * events.
+ *
+ * On devices with a fine pointer and no reduced-motion preference, the grid
+ * drifts a few pixels opposite the cursor for a subtle depth effect. The
+ * motion is CSS `background-position` on a repeating pattern, driven by an
+ * rAF loop that lerps toward the target and stops once settled, so nothing
+ * burns frames while idle.
*/
export function GridBackground() {
+ const ref = React.useRef(null);
+
+ React.useEffect(() => {
+ const el = ref.current;
+ if (!el || typeof window.matchMedia !== "function") return;
+
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const finePointer = window.matchMedia("(pointer: fine)");
+ if (reducedMotion.matches || !finePointer.matches) return;
+
+ let raf = 0;
+ let running = false;
+ let currentX = 0;
+ let currentY = 0;
+ let targetX = 0;
+ let targetY = 0;
+
+ const frame = () => {
+ currentX += (targetX - currentX) * LERP;
+ currentY += (targetY - currentY) * LERP;
+
+ if (
+ Math.abs(targetX - currentX) <= SETTLE &&
+ Math.abs(targetY - currentY) <= SETTLE
+ ) {
+ currentX = targetX;
+ currentY = targetY;
+ running = false;
+ } else {
+ raf = requestAnimationFrame(frame);
+ }
+
+ el.style.backgroundPosition = `${currentX.toFixed(2)}px ${currentY.toFixed(2)}px`;
+ };
+
+ const wake = () => {
+ if (!running) {
+ running = true;
+ raf = requestAnimationFrame(frame);
+ }
+ };
+
+ const handleMove = (event: PointerEvent) => {
+ if (reducedMotion.matches) return;
+
+ const nx = (event.clientX / window.innerWidth) * 2 - 1;
+ const ny = (event.clientY / window.innerHeight) * 2 - 1;
+
+ // Opposite the cursor: the backdrop reads as the far layer.
+ targetX = nx * MAX_SHIFT * -1;
+ targetY = ny * MAX_SHIFT * -1;
+ wake();
+ };
+
+ const handleLeave = () => {
+ targetX = 0;
+ targetY = 0;
+ wake();
+ };
+
+ window.addEventListener("pointermove", handleMove, { passive: true });
+ document.addEventListener("mouseleave", handleLeave);
+ window.addEventListener("blur", handleLeave);
+
+ return () => {
+ window.removeEventListener("pointermove", handleMove);
+ document.removeEventListener("mouseleave", handleLeave);
+ window.removeEventListener("blur", handleLeave);
+ cancelAnimationFrame(raf);
+ };
+ }, []);
+
return (
From 458a51c11ed965fdd833c249cf686492713cec93 Mon Sep 17 00:00:00 2001
From: Andres Estrada <105224908+andesvel@users.noreply.github.com>
Date: Wed, 23 Sep 2026 00:05:58 -0500
Subject: [PATCH 3/6] Swap the line grid for a dot-matrix backdrop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 1px line grid read as graph paper; dots at the same 5% opacity
and 1lh rhythm read as soft texture. Same fixed layer, same cursor
drift, same fallbacks — only the background recipe changes.
---
app/routes/home.tsx | 4 ++--
components/{grid-background.tsx => dot-matrix.tsx} | 10 +++++-----
2 files changed, 7 insertions(+), 7 deletions(-)
rename components/{grid-background.tsx => dot-matrix.tsx} (85%)
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 9d8d793..8a82803 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -7,7 +7,7 @@ import { Features } from "@/components/features";
// import { Separator } from "@/components/ui/separator";
import { NameOrigin } from "@/components/name-origin";
import { CtaSection } from "@/components/cta-section";
-import { GridBackground } from "@/components/grid-background";
+import { DotMatrix } from "@/components/dot-matrix";
// eslint-disable-next-line no-empty-pattern
export function meta({}: Route.MetaArgs) {
@@ -44,7 +44,7 @@ export default function Home() {
-
+
);
}
diff --git a/components/grid-background.tsx b/components/dot-matrix.tsx
similarity index 85%
rename from components/grid-background.tsx
rename to components/dot-matrix.tsx
index 3eb8367..a5597d2 100644
--- a/components/grid-background.tsx
+++ b/components/dot-matrix.tsx
@@ -5,19 +5,19 @@ const LERP = 0.08;
const SETTLE = 0.05;
/**
- * Full-viewport decorative grid backdrop.
+ * Full-viewport decorative dot-matrix backdrop.
*
* Rendered `fixed` so it covers the whole page during scroll and never
* repaints for it. Pure decoration: hidden from a11y tree and ignores pointer
* events.
*
- * On devices with a fine pointer and no reduced-motion preference, the grid
- * drifts a few pixels opposite the cursor for a subtle depth effect. The
+ * On devices with a fine pointer and no reduced-motion preference, the dots
+ * drift a few pixels opposite the cursor for a subtle depth effect. The
* motion is CSS `background-position` on a repeating pattern, driven by an
* rAF loop that lerps toward the target and stops once settled, so nothing
* burns frames while idle.
*/
-export function GridBackground() {
+export function DotMatrix() {
const ref = React.useRef(null);
React.useEffect(() => {
@@ -94,7 +94,7 @@ export function GridBackground() {
);
}
From 513e1ea7370c08abab6956b39f54af8af37ee0e0 Mon Sep 17 00:00:00 2001
From: Andres Estrada <105224908+andesvel@users.noreply.github.com>
Date: Wed, 23 Sep 2026 18:18:57 -0500
Subject: [PATCH 4/6] Add a particle field backdrop to the landing page
Adapts the shadcn.io Particles background as the landing backdrop.
Colors resolve from the theme tokens (logo purple in light, zinc
gray in dark), the mouse position is tracked in a ref instead of
React state, and the DPR transform is set rather than multiplied so
repeated resizes do not zoom the particles.
---
app/routes/home.tsx | 4 +-
components/dot-matrix.tsx | 100 --------------
components/particles.tsx | 280 ++++++++++++++++++++++++++++++++++++++
3 files changed, 282 insertions(+), 102 deletions(-)
delete mode 100644 components/dot-matrix.tsx
create mode 100644 components/particles.tsx
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 8a82803..cdfeaef 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -7,7 +7,7 @@ import { Features } from "@/components/features";
// import { Separator } from "@/components/ui/separator";
import { NameOrigin } from "@/components/name-origin";
import { CtaSection } from "@/components/cta-section";
-import { DotMatrix } from "@/components/dot-matrix";
+import { Particles } from "@/components/particles";
// eslint-disable-next-line no-empty-pattern
export function meta({}: Route.MetaArgs) {
@@ -44,7 +44,7 @@ export default function Home() {
-
+
);
}
diff --git a/components/dot-matrix.tsx b/components/dot-matrix.tsx
deleted file mode 100644
index a5597d2..0000000
--- a/components/dot-matrix.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import * as React from "react";
-
-const MAX_SHIFT = 10;
-const LERP = 0.08;
-const SETTLE = 0.05;
-
-/**
- * Full-viewport decorative dot-matrix backdrop.
- *
- * Rendered `fixed` so it covers the whole page during scroll and never
- * repaints for it. Pure decoration: hidden from a11y tree and ignores pointer
- * events.
- *
- * On devices with a fine pointer and no reduced-motion preference, the dots
- * drift a few pixels opposite the cursor for a subtle depth effect. The
- * motion is CSS `background-position` on a repeating pattern, driven by an
- * rAF loop that lerps toward the target and stops once settled, so nothing
- * burns frames while idle.
- */
-export function DotMatrix() {
- const ref = React.useRef(null);
-
- React.useEffect(() => {
- const el = ref.current;
- if (!el || typeof window.matchMedia !== "function") return;
-
- const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
- const finePointer = window.matchMedia("(pointer: fine)");
- if (reducedMotion.matches || !finePointer.matches) return;
-
- let raf = 0;
- let running = false;
- let currentX = 0;
- let currentY = 0;
- let targetX = 0;
- let targetY = 0;
-
- const frame = () => {
- currentX += (targetX - currentX) * LERP;
- currentY += (targetY - currentY) * LERP;
-
- if (
- Math.abs(targetX - currentX) <= SETTLE &&
- Math.abs(targetY - currentY) <= SETTLE
- ) {
- currentX = targetX;
- currentY = targetY;
- running = false;
- } else {
- raf = requestAnimationFrame(frame);
- }
-
- el.style.backgroundPosition = `${currentX.toFixed(2)}px ${currentY.toFixed(2)}px`;
- };
-
- const wake = () => {
- if (!running) {
- running = true;
- raf = requestAnimationFrame(frame);
- }
- };
-
- const handleMove = (event: PointerEvent) => {
- if (reducedMotion.matches) return;
-
- const nx = (event.clientX / window.innerWidth) * 2 - 1;
- const ny = (event.clientY / window.innerHeight) * 2 - 1;
-
- // Opposite the cursor: the backdrop reads as the far layer.
- targetX = nx * MAX_SHIFT * -1;
- targetY = ny * MAX_SHIFT * -1;
- wake();
- };
-
- const handleLeave = () => {
- targetX = 0;
- targetY = 0;
- wake();
- };
-
- window.addEventListener("pointermove", handleMove, { passive: true });
- document.addEventListener("mouseleave", handleLeave);
- window.addEventListener("blur", handleLeave);
-
- return () => {
- window.removeEventListener("pointermove", handleMove);
- document.removeEventListener("mouseleave", handleLeave);
- window.removeEventListener("blur", handleLeave);
- cancelAnimationFrame(raf);
- };
- }, []);
-
- return (
-
- );
-}
diff --git a/components/particles.tsx b/components/particles.tsx
new file mode 100644
index 0000000..7f3672e
--- /dev/null
+++ b/components/particles.tsx
@@ -0,0 +1,280 @@
+import * as React from "react";
+import { cn } from "lib/utils";
+
+/**
+ * Canvas particle field, adapted from the shadcn.io "Particles" background.
+ *
+ * Differences from the reference implementation: theme colors are resolved
+ * from the design tokens (logo purple in light, zinc gray in dark) instead
+ * of a fixed white, the mouse position is tracked in a ref instead of React
+ * state (no re-render per mousemove), the DPR transform is set instead of
+ * multiplied, and reduced-motion users get a single static frame.
+ */
+interface ParticleProps {
+ className?: string;
+ /** Number of particles */
+ quantity?: number;
+ /** Higher values reduce the magnetism pull toward the cursor */
+ staticity?: number;
+ /** Higher values ease the magnetism movement */
+ ease?: number;
+ /** Base particle size in pixels */
+ size?: number;
+ /** Re-randomize the particles when it changes */
+ refresh?: boolean;
+ /** Explicit particle color; when omitted it follows the theme */
+ color?: string;
+ vx?: number;
+ vy?: number;
+}
+
+// The backdrop's theme colors, sampled from the CSS tokens:
+// light uses the logo purple (--primary) and dark uses the zinc gray
+// the original grid used (--muted-foreground).
+const LIGHT_COLOR = "#5a58ff";
+const DARK_COLOR = "#a1a1aa";
+
+function hexToRgb(hex: string): number[] {
+ let normalized = hex.replace("#", "");
+
+ if (normalized.length === 3) {
+ normalized = normalized
+ .split("")
+ .map((char) => char + char)
+ .join("");
+ }
+
+ const hexInt = Number.parseInt(normalized, 16);
+ return [(hexInt >> 16) & 255, (hexInt >> 8) & 255, hexInt & 255];
+}
+
+interface Circle {
+ x: number;
+ y: number;
+ translateX: number;
+ translateY: number;
+ size: number;
+ alpha: number;
+ targetAlpha: number;
+ dx: number;
+ dy: number;
+ magnetism: number;
+}
+
+function remapValue(
+ value: number,
+ start1: number,
+ end1: number,
+ start2: number,
+ end2: number
+): number {
+ const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
+ return remapped > 0 ? remapped : 0;
+}
+
+export function Particles({
+ className,
+ quantity = 100,
+ staticity = 50,
+ ease = 50,
+ size = 0.4,
+ refresh = false,
+ color,
+ vx = 0,
+ vy = 0,
+}: ParticleProps) {
+ const canvasRef = React.useRef(null);
+ const containerRef = React.useRef(null);
+ const circlesRef = React.useRef([]);
+ const mouseRef = React.useRef({ x: 0, y: 0 });
+ const canvasSizeRef = React.useRef({ w: 0, h: 0 });
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
+
+ const [resolvedColor, setResolvedColor] = React.useState(
+ color ?? LIGHT_COLOR
+ );
+
+ React.useEffect(() => {
+ if (color) {
+ setResolvedColor(color);
+ return;
+ }
+ const sync = () => {
+ setResolvedColor(
+ document.documentElement.classList.contains("dark")
+ ? DARK_COLOR
+ : LIGHT_COLOR
+ );
+ };
+ sync();
+ const observer = new MutationObserver(sync);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ return () => observer.disconnect();
+ }, [color]);
+
+ React.useEffect(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container) return;
+ const context = canvas.getContext("2d");
+ if (!context) return;
+
+ const rgb = hexToRgb(resolvedColor);
+ let raf = 0;
+
+ const resizeCanvas = () => {
+ circlesRef.current.length = 0;
+ canvasSizeRef.current.w = container.offsetWidth;
+ canvasSizeRef.current.h = container.offsetHeight;
+ canvas.width = canvasSizeRef.current.w * dpr;
+ canvas.height = canvasSizeRef.current.h * dpr;
+ canvas.style.width = `${canvasSizeRef.current.w}px`;
+ canvas.style.height = `${canvasSizeRef.current.h}px`;
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+
+ const circleParams = (): Circle => ({
+ x: Math.floor(Math.random() * canvasSizeRef.current.w),
+ y: Math.floor(Math.random() * canvasSizeRef.current.h),
+ translateX: 0,
+ translateY: 0,
+ size: Math.floor(Math.random() * 2) + size,
+ alpha: 0,
+ targetAlpha: Number.parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)),
+ dx: (Math.random() - 0.5) * 0.1,
+ dy: (Math.random() - 0.5) * 0.1,
+ magnetism: 0.1 + Math.random() * 4,
+ });
+
+ const drawCircle = (circle: Circle, update = false) => {
+ const { x, y, translateX, translateY, size, alpha } = circle;
+ context.translate(translateX, translateY);
+ context.beginPath();
+ context.arc(x, y, size, 0, 2 * Math.PI);
+ context.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
+ context.fill();
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ if (!update) {
+ circlesRef.current.push(circle);
+ }
+ };
+
+ const clearContext = () => {
+ context.clearRect(0, 0, canvasSizeRef.current.w, canvasSizeRef.current.h);
+ };
+
+ const drawParticles = (opaque = false) => {
+ clearContext();
+ for (let i = 0; i < quantity; i++) {
+ const circle = circleParams();
+ if (opaque) circle.alpha = circle.targetAlpha;
+ drawCircle(circle);
+ }
+ };
+
+ const animate = () => {
+ clearContext();
+ circlesRef.current.forEach((circle, index) => {
+ const edge = [
+ circle.x + circle.translateX - circle.size,
+ canvasSizeRef.current.w - circle.x - circle.translateX - circle.size,
+ circle.y + circle.translateY - circle.size,
+ canvasSizeRef.current.h - circle.y - circle.translateY - circle.size,
+ ];
+ const closestEdge = edge.reduce((a, b) => Math.min(a, b));
+ const remapClosestEdge = Number.parseFloat(
+ remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
+ );
+ if (remapClosestEdge > 1) {
+ circle.alpha += 0.02;
+ if (circle.alpha > circle.targetAlpha) {
+ circle.alpha = circle.targetAlpha;
+ }
+ } else {
+ circle.alpha = circle.targetAlpha * remapClosestEdge;
+ }
+ circle.x += circle.dx + vx;
+ circle.y += circle.dy + vy;
+ circle.translateX +=
+ (mouseRef.current.x / (staticity / circle.magnetism) -
+ circle.translateX) /
+ ease;
+ circle.translateY +=
+ (mouseRef.current.y / (staticity / circle.magnetism) -
+ circle.translateY) /
+ ease;
+
+ drawCircle(circle, true);
+
+ if (
+ circle.x < -circle.size ||
+ circle.x > canvasSizeRef.current.w + circle.size ||
+ circle.y < -circle.size ||
+ circle.y > canvasSizeRef.current.h + circle.size
+ ) {
+ circlesRef.current.splice(index, 1);
+ drawCircle(circleParams());
+ }
+ });
+ raf = window.requestAnimationFrame(animate);
+ };
+
+ const initCanvas = () => {
+ resizeCanvas();
+ drawParticles();
+ };
+
+ const handleMove = (event: PointerEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ const { w, h } = canvasSizeRef.current;
+ const x = event.clientX - rect.left - w / 2;
+ const y = event.clientY - rect.top - h / 2;
+ if (x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2) {
+ mouseRef.current = { x, y };
+ }
+ };
+
+ const handleLeave = () => {
+ mouseRef.current = { x: 0, y: 0 };
+ };
+
+ const reducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+
+ resizeCanvas();
+ if (reducedMotion) {
+ drawParticles(true);
+ } else {
+ drawParticles();
+ raf = window.requestAnimationFrame(animate);
+ }
+ window.addEventListener("resize", initCanvas);
+ window.addEventListener("pointermove", handleMove, { passive: true });
+ document.addEventListener("mouseleave", handleLeave);
+
+ return () => {
+ if (raf) window.cancelAnimationFrame(raf);
+ window.removeEventListener("resize", initCanvas);
+ window.removeEventListener("pointermove", handleMove);
+ document.removeEventListener("mouseleave", handleLeave);
+ };
+ }, [resolvedColor, refresh, quantity, size, staticity, ease, vx, vy, dpr]);
+
+ return (
+
+
+
+ );
+}
From 361021377383323712677018d70735f586dc79ff Mon Sep 17 00:00:00 2001
From: Andres Estrada <105224908+andesvel@users.noreply.github.com>
Date: Wed, 23 Sep 2026 18:31:44 -0500
Subject: [PATCH 5/6] Make the particle field scroll with the page content
Particles now live in document coordinates and are drawn offset by
the scroll position, so they travel with the content while the canvas
stays a viewport-sized layer. quantity becomes a density per viewport
and scales across the document; particles fade in and out as they
cross the visible screen, wrap only at the document edges, and
respawn within the current viewport band. Reduced motion keeps a
static field that still tracks scroll.
---
components/particles.tsx | 138 +++++++++++++++++++++++++++++----------
1 file changed, 103 insertions(+), 35 deletions(-)
diff --git a/components/particles.tsx b/components/particles.tsx
index 7f3672e..70f5c83 100644
--- a/components/particles.tsx
+++ b/components/particles.tsx
@@ -2,13 +2,19 @@ import * as React from "react";
import { cn } from "lib/utils";
/**
- * Canvas particle field, adapted from the shadcn.io "Particles" background.
+ * Canvas particle field that scrolls with the page content, adapted from
+ * the shadcn.io "Particles" background.
+ *
+ * The canvas is a fixed viewport-sized layer; particles live in document
+ * coordinates and are drawn offset by the scroll position, so they travel
+ * with the content while memory stays bounded to one viewport. `quantity`
+ * is the density per viewport height, scaled across the whole document.
*
* Differences from the reference implementation: theme colors are resolved
* from the design tokens (logo purple in light, zinc gray in dark) instead
* of a fixed white, the mouse position is tracked in a ref instead of React
* state (no re-render per mousemove), the DPR transform is set instead of
- * multiplied, and reduced-motion users get a single static frame.
+ * multiplied, and reduced-motion users get a scroll-aware static field.
*/
interface ParticleProps {
className?: string;
@@ -88,6 +94,7 @@ export function Particles({
const circlesRef = React.useRef([]);
const mouseRef = React.useRef({ x: 0, y: 0 });
const canvasSizeRef = React.useRef({ w: 0, h: 0 });
+ const pageHeightRef = React.useRef(0);
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
const [resolvedColor, setResolvedColor] = React.useState(
@@ -129,6 +136,7 @@ export function Particles({
circlesRef.current.length = 0;
canvasSizeRef.current.w = container.offsetWidth;
canvasSizeRef.current.h = container.offsetHeight;
+ pageHeightRef.current = document.documentElement.scrollHeight;
canvas.width = canvasSizeRef.current.w * dpr;
canvas.height = canvasSizeRef.current.h * dpr;
canvas.style.width = `${canvasSizeRef.current.w}px`;
@@ -136,9 +144,16 @@ export function Particles({
context.setTransform(dpr, 0, 0, dpr, 0, 0);
};
+ // The prop is a density per viewport; a taller document gets more
+ // particles so the field looks the same at every scroll position.
+ const totalParticles = () => {
+ const screens = Math.max(1, pageHeightRef.current / canvasSizeRef.current.h);
+ return Math.round(quantity * screens);
+ };
+
const circleParams = (): Circle => ({
x: Math.floor(Math.random() * canvasSizeRef.current.w),
- y: Math.floor(Math.random() * canvasSizeRef.current.h),
+ y: Math.floor(Math.random() * pageHeightRef.current),
translateX: 0,
translateY: 0,
size: Math.floor(Math.random() * 2) + size,
@@ -149,11 +164,19 @@ export function Particles({
magnetism: 0.1 + Math.random() * 4,
});
- const drawCircle = (circle: Circle, update = false) => {
- const { x, y, translateX, translateY, size, alpha } = circle;
+ // Respawns land inside the current viewport band so density follows the
+ // user instead of thinning out over time.
+ const spawnInView = (): Circle => {
+ const circle = circleParams();
+ circle.y = Math.floor(window.scrollY + Math.random() * canvasSizeRef.current.h);
+ return circle;
+ };
+
+ const drawCircle = (circle: Circle, drawY: number, update = false) => {
+ const { x, translateX, translateY, size, alpha } = circle;
context.translate(translateX, translateY);
context.beginPath();
- context.arc(x, y, size, 0, 2 * Math.PI);
+ context.arc(x, drawY, size, 0, 2 * Math.PI);
context.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
context.fill();
context.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -167,36 +190,39 @@ export function Particles({
context.clearRect(0, 0, canvasSizeRef.current.w, canvasSizeRef.current.h);
};
- const drawParticles = (opaque = false) => {
+ const spawnParticles = (opaque = false) => {
clearContext();
- for (let i = 0; i < quantity; i++) {
+ const scrollY = window.scrollY;
+ const { h } = canvasSizeRef.current;
+ for (let i = 0; i < totalParticles(); i++) {
const circle = circleParams();
if (opaque) circle.alpha = circle.targetAlpha;
- drawCircle(circle);
+ circlesRef.current.push(circle);
+ const vy = circle.y - scrollY;
+ if (vy >= -circle.size && vy <= h + circle.size) {
+ drawCircle(circle, vy, true);
+ }
+ }
+ };
+
+ const drawStatic = () => {
+ clearContext();
+ const scrollY = window.scrollY;
+ const { h } = canvasSizeRef.current;
+ for (const circle of circlesRef.current) {
+ const vy = circle.y - scrollY;
+ if (vy < -circle.size || vy > h + circle.size) continue;
+ circle.alpha = circle.targetAlpha;
+ drawCircle(circle, vy, true);
}
};
const animate = () => {
clearContext();
+ const scrollY = window.scrollY;
+ const { w, h } = canvasSizeRef.current;
+ const pageH = pageHeightRef.current;
circlesRef.current.forEach((circle, index) => {
- const edge = [
- circle.x + circle.translateX - circle.size,
- canvasSizeRef.current.w - circle.x - circle.translateX - circle.size,
- circle.y + circle.translateY - circle.size,
- canvasSizeRef.current.h - circle.y - circle.translateY - circle.size,
- ];
- const closestEdge = edge.reduce((a, b) => Math.min(a, b));
- const remapClosestEdge = Number.parseFloat(
- remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
- );
- if (remapClosestEdge > 1) {
- circle.alpha += 0.02;
- if (circle.alpha > circle.targetAlpha) {
- circle.alpha = circle.targetAlpha;
- }
- } else {
- circle.alpha = circle.targetAlpha * remapClosestEdge;
- }
circle.x += circle.dx + vx;
circle.y += circle.dy + vy;
circle.translateX +=
@@ -208,24 +234,52 @@ export function Particles({
circle.translateY) /
ease;
- drawCircle(circle, true);
-
+ // Wrap only at the document edges; particles between viewports
+ // simply wait offscreen until they scroll back into view.
if (
circle.x < -circle.size ||
- circle.x > canvasSizeRef.current.w + circle.size ||
+ circle.x > w + circle.size ||
circle.y < -circle.size ||
- circle.y > canvasSizeRef.current.h + circle.size
+ circle.y > pageH + circle.size
) {
circlesRef.current.splice(index, 1);
- drawCircle(circleParams());
+ const fresh = spawnInView();
+ drawCircle(fresh, fresh.y - scrollY);
+ return;
+ }
+
+ const drawY = circle.y - scrollY;
+ if (drawY < -circle.size || drawY > h + circle.size) return;
+
+ // Fade relative to the visible screen so particles enter and leave
+ // smoothly while scrolling.
+ const edge = [
+ circle.x + circle.translateX - circle.size,
+ w - circle.x - circle.translateX - circle.size,
+ drawY + circle.translateY - circle.size,
+ h - drawY - circle.translateY - circle.size,
+ ];
+ const closestEdge = edge.reduce((a, b) => Math.min(a, b));
+ const remapClosestEdge = Number.parseFloat(
+ remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
+ );
+ if (remapClosestEdge > 1) {
+ circle.alpha += 0.02;
+ if (circle.alpha > circle.targetAlpha) {
+ circle.alpha = circle.targetAlpha;
+ }
+ } else {
+ circle.alpha = circle.targetAlpha * remapClosestEdge;
}
+
+ drawCircle(circle, drawY, true);
});
raf = window.requestAnimationFrame(animate);
};
const initCanvas = () => {
resizeCanvas();
- drawParticles();
+ spawnParticles();
};
const handleMove = (event: PointerEvent) => {
@@ -247,21 +301,35 @@ export function Particles({
).matches;
resizeCanvas();
+ spawnParticles(reducedMotion);
if (reducedMotion) {
- drawParticles(true);
+ // No drift or magnetism, but the field still tracks scroll so it
+ // moves with the content like everywhere else.
+ window.addEventListener("scroll", drawStatic, { passive: true });
} else {
- drawParticles();
raf = window.requestAnimationFrame(animate);
}
window.addEventListener("resize", initCanvas);
window.addEventListener("pointermove", handleMove, { passive: true });
document.addEventListener("mouseleave", handleLeave);
+ // Document height changes (content loading, viewport resizing) re-lay
+ // the particle field.
+ const docObserver = new ResizeObserver(() => {
+ if (document.documentElement.scrollHeight === pageHeightRef.current) {
+ return;
+ }
+ initCanvas();
+ });
+ docObserver.observe(document.documentElement);
+
return () => {
if (raf) window.cancelAnimationFrame(raf);
window.removeEventListener("resize", initCanvas);
window.removeEventListener("pointermove", handleMove);
document.removeEventListener("mouseleave", handleLeave);
+ window.removeEventListener("scroll", drawStatic);
+ docObserver.disconnect();
};
}, [resolvedColor, refresh, quantity, size, staticity, ease, vx, vy, dpr]);
From 4eeb721aab9a3e322e95e94cbd4ab6def1b9ee7e Mon Sep 17 00:00:00 2001
From: Andres Estrada <105224908+andesvel@users.noreply.github.com>
Date: Wed, 23 Sep 2026 18:31:45 -0500
Subject: [PATCH 6/6] Raise the particle density slightly
---
components/particles.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/particles.tsx b/components/particles.tsx
index 70f5c83..8da20ff 100644
--- a/components/particles.tsx
+++ b/components/particles.tsx
@@ -80,7 +80,7 @@ function remapValue(
export function Particles({
className,
- quantity = 100,
+ quantity = 140,
staticity = 50,
ease = 50,
size = 0.4,