diff --git a/app/page.tsx b/app/page.tsx
index 929176a..6da3dab 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,6 +6,7 @@ import { StatBar } from '@/components/landing/StatBar';
import { CorridorStrip } from '@/components/landing/CorridorStrip';
import { ComparisonTeaser } from '@/components/landing/ComparisonTeaser';
import { Faq } from '@/components/landing/Faq';
+import { LandingSection } from '@/components/landing/LandingSection';
import { registryStats } from '@/constants';
export default function HomePage() {
@@ -13,52 +14,58 @@ export default function HomePage() {
return (
{/* Hero */}
-
+
+
+
{/* Stat bar — counts derived from the anchor registry */}
-
- ),
- value: stats.anchors,
- label: 'Anchors tracked',
- },
- {
- icon: (
-
- ),
- value: stats.corridors,
- label: 'Corridors live',
- },
- {
- icon: (
-
- ),
- value: stats.countries,
- label: 'Countries reachable',
- },
- ]}
- />
+
+
+ ),
+ value: stats.anchors,
+ label: 'Anchors tracked',
+ },
+ {
+ icon: (
+
+ ),
+ value: stats.corridors,
+ label: 'Corridors live',
+ },
+ {
+ icon: (
+
+ ),
+ value: stats.countries,
+ label: 'Countries reachable',
+ },
+ ]}
+ />
+
{/* Supported corridors */}
-
+
+
+
{/* Comparison teaser */}
{/* Module card */}
-
+
Start executing
@@ -82,10 +89,13 @@ export default function HomePage() {
-
+
{/* Explainer */}
-
+
How it works
{[
@@ -116,10 +126,12 @@ export default function HomePage() {
))}
-
+
{/* FAQ */}
-
+
+
+
);
}
diff --git a/components/landing/LandingSection.tsx b/components/landing/LandingSection.tsx
new file mode 100644
index 0000000..c0eab48
--- /dev/null
+++ b/components/landing/LandingSection.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import React from 'react';
+import { clsx } from 'clsx';
+import { useInView } from '@/hooks/useInView';
+
+interface LandingSectionProps extends React.HTMLAttributes {
+ children: React.ReactNode;
+ delay?: number;
+}
+
+export function LandingSection({ children, className, delay = 0, ...props }: LandingSectionProps) {
+ const [ref, inView] = useInView({ threshold: 0.1, triggerOnce: true });
+
+ return (
+
+ );
+}
diff --git a/hooks/useInView.ts b/hooks/useInView.ts
new file mode 100644
index 0000000..5596087
--- /dev/null
+++ b/hooks/useInView.ts
@@ -0,0 +1,65 @@
+import { useEffect, useRef, useState } from 'react';
+
+interface UseInViewOptions {
+ threshold?: number | number[];
+ rootMargin?: string;
+ triggerOnce?: boolean;
+}
+
+export function useInView(options: UseInViewOptions = {}) {
+ const { threshold = 0.1, rootMargin = '0px', triggerOnce = true } = options;
+ const [inView, setInView] = useState(() => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ });
+ const ref = useRef(null);
+
+ const thresholdString = typeof threshold === 'object' ? JSON.stringify(threshold) : threshold;
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ return;
+ }
+
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+
+ const observerThreshold =
+ typeof thresholdString === 'string' ? JSON.parse(thresholdString) : thresholdString;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (entry && entry.isIntersecting) {
+ setInView(true);
+ if (triggerOnce) {
+ observer.unobserve(element);
+ }
+ } else if (!triggerOnce) {
+ setInView(false);
+ }
+ },
+ {
+ threshold: observerThreshold,
+ rootMargin,
+ }
+ );
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [thresholdString, rootMargin, triggerOnce]);
+
+ return [ref, inView] as const;
+}
diff --git a/tests/components/LandingSection.test.tsx b/tests/components/LandingSection.test.tsx
new file mode 100644
index 0000000..27de01e
--- /dev/null
+++ b/tests/components/LandingSection.test.tsx
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import React from 'react';
+import { LandingSection } from '@/components/landing/LandingSection';
+
+describe('LandingSection component', () => {
+ let observerCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ observerCallback = null;
+
+ class MockIntersectionObserver {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+
+ constructor(callback: (entries: { isIntersecting: boolean }[]) => void) {
+ observerCallback = callback;
+ }
+ }
+
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+ );
+ });
+
+ it('renders children inside a section and starts hidden', () => {
+ render(
+
+ Content
+
+ );
+
+ const section = screen.getByTestId('landing-section');
+ expect(section.tagName).toBe('SECTION');
+ expect(section).toHaveTextContent('Content');
+ expect(section.className).toContain('opacity-0');
+ expect(section.className).toContain('translate-y-8');
+ });
+
+ it('applies the visible state once the section intersects', () => {
+ render(
+
+ Content
+
+ );
+
+ act(() => {
+ observerCallback?.([{ isIntersecting: true }]);
+ });
+
+ const section = screen.getByTestId('landing-section');
+ expect(section.className).toContain('opacity-100');
+ expect(section.className).toContain('translate-y-0');
+ });
+
+ it('supports an animation delay prop', () => {
+ render(
+
+ Content
+
+ );
+
+ const section = screen.getByTestId('landing-section');
+ expect(section.style.transitionDelay).toBe('0ms');
+
+ act(() => {
+ observerCallback?.([{ isIntersecting: true }]);
+ });
+
+ expect(section.style.transitionDelay).toBe('150ms');
+ });
+
+ it('stays fully visible under reduced motion', () => {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query: string) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+ );
+
+ render(
+
+ Content
+
+ );
+
+ const section = screen.getByTestId('landing-section');
+ expect(section.className).toContain('opacity-100');
+ expect(section.className).toContain('translate-y-0');
+ expect(section.className).toContain('motion-reduce:transition-none');
+ });
+});
diff --git a/tests/components/__snapshots__/HomePage.test.tsx.snap b/tests/components/__snapshots__/HomePage.test.tsx.snap
index 5327587..3e454d9 100644
--- a/tests/components/__snapshots__/HomePage.test.tsx.snap
+++ b/tests/components/__snapshots__/HomePage.test.tsx.snap
@@ -5,298 +5,313 @@ exports[`HomePage > matches snapshot 1`] = `
class="space-y-8 sm:space-y-16"
>
-
-
-
-
- Stellar Execution Layer
-
-
- The execution layer for
-
-
+
+
+ Stellar Execution Layer
+
+
- stablecoin off-ramps.
-
-
-
- Compare live SEP-38 quotes across every Stellar anchor, then settle a non-custodial USDC off-ramp to Nigeria, Kenya, Ghana, Mexico, and more — in a single signed intent.
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 3
-
+
+
+
+
+
+
+
+
+
+
+
- 0
+
+ 3
+
+
+ 0
+
-
+
+
+
+ Anchors tracked
-
-
- Anchors tracked
-
-
-
-
-
-
-
-
-
-
-
-
- 2
-
+
+
+
+
+
+
+
+
- 0
+
+ 2
+
+
+ 0
+
-
+
+
+
+ Corridors live
-
-
- Corridors live
-
-
-
-
-
-
-
-
-
-
-
-
- 2
-
+
+
+
+
+
+
+
+
- 0
+
+ 2
+
+
+ 0
+
-
+
+
+
+ Countries reachable
-
-
- Countries reachable
-
+
-
`;
diff --git a/tests/hooks/useInView.test.tsx b/tests/hooks/useInView.test.tsx
new file mode 100644
index 0000000..f6baaf4
--- /dev/null
+++ b/tests/hooks/useInView.test.tsx
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import React from 'react';
+import { useInView } from '@/hooks/useInView';
+
+function TestComponent({ options }: { options?: Parameters
[0] }) {
+ const [ref, inView] = useInView(options);
+
+ return (
+ } data-testid="target">
+ {inView ? 'IN_VIEW' : 'NOT_IN_VIEW'}
+
+ );
+}
+
+describe('useInView hook', () => {
+ let observeMock = vi.fn();
+ let unobserveMock = vi.fn();
+ let disconnectMock = vi.fn();
+ let observerCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ observeMock = vi.fn();
+ unobserveMock = vi.fn();
+ disconnectMock = vi.fn();
+ observerCallback = null;
+
+ class MockIntersectionObserver {
+ observe = observeMock;
+ unobserve = unobserveMock;
+ disconnect = disconnectMock;
+
+ constructor(callback: (entries: { isIntersecting: boolean }[]) => void) {
+ observerCallback = callback;
+ }
+ }
+
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+ );
+ });
+
+ it('initializes as not in view and starts observing', () => {
+ render( );
+
+ expect(screen.getByTestId('target')).toHaveTextContent('NOT_IN_VIEW');
+ expect(observeMock).toHaveBeenCalled();
+ });
+
+ it('switches to in view when the element intersects', () => {
+ render( );
+
+ act(() => {
+ observerCallback?.([{ isIntersecting: true }]);
+ });
+
+ expect(screen.getByTestId('target')).toHaveTextContent('IN_VIEW');
+ });
+
+ it('stops observing after the first intersection when triggerOnce is true', () => {
+ render( );
+
+ act(() => {
+ observerCallback?.([{ isIntersecting: true }]);
+ });
+
+ expect(unobserveMock).toHaveBeenCalled();
+ });
+
+ it('keeps observing and toggles visibility when triggerOnce is false', () => {
+ render( );
+
+ act(() => {
+ observerCallback?.([{ isIntersecting: true }]);
+ });
+ expect(screen.getByTestId('target')).toHaveTextContent('IN_VIEW');
+
+ act(() => {
+ observerCallback?.([{ isIntersecting: false }]);
+ });
+ expect(screen.getByTestId('target')).toHaveTextContent('NOT_IN_VIEW');
+ });
+
+ it('skips the observer when reduced motion is preferred', () => {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query: string) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+ );
+
+ render( );
+
+ expect(screen.getByTestId('target')).toHaveTextContent('IN_VIEW');
+ expect(observeMock).not.toHaveBeenCalled();
+ });
+});