diff --git a/src/components/effects/GradientBlinds.test.tsx b/src/components/effects/GradientBlinds.test.tsx
new file mode 100644
index 0000000..4a2789c
--- /dev/null
+++ b/src/components/effects/GradientBlinds.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+import { render, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import GradientBlinds from './GradientBlinds';
+
+// Mock ogl
+vi.mock('ogl', () => {
+ return {
+ Renderer: class {
+ gl = {
+ // Use a real canvas element so appendChild works
+ canvas: document.createElement('canvas'),
+ drawingBufferWidth: 100,
+ drawingBufferHeight: 100,
+ };
+ setSize() {}
+ render() {}
+ },
+ Program: class {
+ remove() {}
+ },
+ Mesh: class {
+ remove() {}
+ },
+ Triangle: class {
+ remove() {}
+ },
+ };
+});
+
+// Mock ResizeObserver
+globalThis.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};
+
+describe('GradientBlinds', () => {
+ let rafSpy: any;
+ let cancelRafSpy: any;
+ let ioCallback: (entries: IntersectionObserverEntry[]) => void;
+ let observeSpy: any;
+ let disconnectSpy: any;
+
+ beforeEach(() => {
+ rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((_cb: any) => {
+ return 123;
+ });
+ cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((_id: number) => {
+ });
+
+ // Mock IntersectionObserver
+ observeSpy = vi.fn();
+ disconnectSpy = vi.fn();
+ (globalThis as any).IntersectionObserver = class IntersectionObserver {
+ constructor(cb: any) {
+ ioCallback = cb;
+ }
+ observe = observeSpy;
+ unobserve = vi.fn();
+ disconnect = disconnectSpy;
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('starts animation loop on mount', () => {
+ const { unmount } = render();
+ expect(rafSpy).toHaveBeenCalled();
+ unmount();
+ expect(cancelRafSpy).toHaveBeenCalled();
+ });
+
+ it('pauses animation loop when off-screen and resumes when on-screen', () => {
+ const { unmount } = render();
+
+ // Initial start
+ expect(rafSpy).toHaveBeenCalledTimes(1);
+
+ // Reset spies to check for subsequent calls
+ rafSpy.mockClear();
+ cancelRafSpy.mockClear();
+
+ // Simulate off-screen
+ act(() => {
+ if (ioCallback) {
+ ioCallback([{ isIntersecting: false } as IntersectionObserverEntry]);
+ }
+ });
+
+ // Should cancel animation frame
+ expect(cancelRafSpy).toHaveBeenCalled();
+
+ // Should NOT request new frame (loop stopped)
+ expect(rafSpy).not.toHaveBeenCalled();
+
+ // Reset spies
+ cancelRafSpy.mockClear();
+
+ // Simulate on-screen
+ act(() => {
+ if (ioCallback) {
+ ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]);
+ }
+ });
+
+ // Should restart animation loop
+ expect(rafSpy).toHaveBeenCalled();
+
+ unmount();
+ expect(disconnectSpy).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/effects/GradientBlinds.tsx b/src/components/effects/GradientBlinds.tsx
index b926c84..735c48e 100644
--- a/src/components/effects/GradientBlinds.tsx
+++ b/src/components/effects/GradientBlinds.tsx
@@ -333,11 +333,28 @@ void main() {
}
}
};
+
+ const observer = new IntersectionObserver(([entry]) => {
+ if (entry.isIntersecting) {
+ if (!rafRef.current) {
+ lastTimeRef.current = 0;
+ rafRef.current = requestAnimationFrame(loop);
+ }
+ } else {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ }
+ });
+ observer.observe(container);
+
rafRef.current = requestAnimationFrame(loop);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
canvas.removeEventListener('pointermove', onPointerMove);
+ observer.disconnect();
ro.disconnect();
if (canvas.parentElement === container) {
container.removeChild(canvas);