Skip to content
Download

Observer & track

The observer and track systems provide advanced viewport detection and scroll tracking capabilities with efficient memory management.

The system provides:

  • Observer System: Advanced Intersection Observer with grouping and management
  • Track System: Scroll-based tracking with precise bounds calculation
  • Memory Management: Efficient observer grouping and automatic cleanup
  • Direction Detection: Enhanced scroll direction detection for animations
  • Performance Optimization: Optimized observer management and subscription handling

A singleton that manages all Intersection Observers efficiently:

import { ObserverManager } from "@/modules/_/observe";
// Get singleton instance
const manager = ObserverManager.getInstance();

The main observer class for viewport detection:

import { Observe } from "@/modules/_/observe";
const observer = new Observe(element, {
root: null, // Root element (null = viewport)
rootMargin: "0px", // Margin around root
threshold: 0, // Visibility threshold
autoStart: false, // Start automatically
once: false, // Trigger only once
callback: (data) => {
// Custom callback
console.log("Observer event:", data);
},
});
interface ObserveConfig {
root?: HTMLElement | null; // Root element for intersection
rootMargin?: string; // Margin around root (CSS-like)
threshold?: number; // Visibility threshold (0-1)
autoStart?: boolean; // Start observer automatically
once?: boolean; // Trigger only once
callback?: (data: ObserveEventData) => void; // Custom callback
}
interface ObserveEventData {
entry: IntersectionObserverEntry; // Native intersection entry
direction: number; // Scroll direction (-1 = up, 1 = down)
isIn: boolean; // Whether element is in view
}
export default function (element: HTMLElement, dataset: DOMStringMap) {
const observer = new Observe(element, {
threshold: 0.1, // Trigger when 10% visible
rootMargin: "50px", // 50px margin around viewport
autoStart: true, // Start immediately
callback: ({ isIn, direction }) => {
if (isIn) {
element.classList.add("in-view");
console.log("Element entered viewport");
} else {
element.classList.remove("in-view");
console.log("Element left viewport");
}
},
});
// Manual control
observer.start();
observer.stop();
observer.destroy();
// Auto-cleanup with lifecycle hooks
onDestroy(() => {
observer.destroy();
});
}
const observer = new Observe(element, {
threshold: 0.1,
callback: ({ isIn, direction }) => {
if (isIn) {
// Animate in based on direction
if (direction > 0) {
// Scrolling down - animate from bottom
gsap.fromTo(
element,
{ y: 50, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.6 },
);
} else {
// Scrolling up - animate from top
gsap.fromTo(
element,
{ y: -50, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.6 },
);
}
}
},
});
export default function (element: HTMLElement, dataset: DOMStringMap) {
const children = Array.from(element.children) as HTMLElement[];
children.forEach((child, index) => {
const childObserver = new Observe(child, {
threshold: 0.1,
callback: ({ isIn }) => {
if (isIn) {
gsap.to(child, {
opacity: 1,
y: 0,
duration: 0.5,
delay: index * 0.1, // Stagger effect
ease: "power2.out",
});
}
},
});
onDestroy(() => childObserver.destroy());
});
}

The Track system extends the Observer system to provide scroll-based tracking with precise bounds calculation:

import { Track } from "@/modules/_/track";
const track = new Track(element, {
bounds: [0, 1], // Output range
top: "bottom", // When to start tracking
bottom: "top", // When to stop tracking
callback: (value) => {
// Progress callback
console.log("Track progress:", value);
},
});
interface TrackConfig {
bounds: [number, number]; // Output range [min, max]
top: "top" | "center" | "bottom"; // Start trigger point
bottom: "top" | "center" | "bottom"; // End trigger point
callback?: (value: number) => void; // Progress callback
}

The track system supports three trigger points:

  • “top”: Element’s top edge
  • “center”: Element’s center point
  • “bottom”: Element’s bottom edge
export default function (element: HTMLElement, dataset: DOMStringMap) {
const track = new Track(element, {
bounds: [0, 1], // Output 0-1
top: "bottom", // Start when bottom hits viewport top
bottom: "top", // End when top hits viewport bottom
callback: (value) => {
// value goes from 0 to 1 as element scrolls through viewport
element.style.setProperty("--scroll-progress", value.toString());
},
});
// Auto-cleanup
onDestroy(() => {
track.destroy();
});
}
const track = new Track(element, {
bounds: [0, 200], // Move 200px
top: "bottom",
bottom: "top",
callback: (value) => {
// Parallax effect
element.style.transform = `translateY(${value}px)`;
},
});
const track = new Track(element, {
bounds: [0, 360], // Rotate 360 degrees
top: "center",
bottom: "center",
callback: (value) => {
element.style.transform = `rotate(${value}deg)`;
},
});
const track = new Track(element, {
bounds: [0.5, 1], // Scale from 50% to 100%
top: "bottom",
bottom: "top",
callback: (value) => {
element.style.transform = `scale(${value})`;
},
});
const track = new Track(element, {
bounds: [0, 1],
top: "bottom",
bottom: "top",
callback: (value) => {
// Multiple properties
element.style.transform = `
translateY(${value * 100}px)
rotate(${value * 180}deg)
scale(${0.5 + value * 0.5})
`;
// Opacity
element.style.opacity = value.toString();
// CSS custom properties
element.style.setProperty("--progress", value.toString());
},
});

The track system automatically calculates bounds based on:

  1. Element Position: Current element position in viewport
  2. Trigger Points: Start and end trigger points
  3. Viewport Size: Current viewport dimensions
  4. Scroll Position: Current scroll position
// Manual bounds calculation (for reference)
function computeBounds(el: HTMLElement, config: TrackConfig) {
const bounds = clientRect(el);
const { top: topPos, bottom: bottomPos, wh } = bounds;
const centerOffset = wh / 2;
bounds.top =
topPos -
(config.top === "center" ? centerOffset : config.top === "bottom" ? wh : 0);
bounds.bottom =
bottomPos -
(config.bottom === "center"
? centerOffset
: config.bottom === "bottom"
? wh
: 0);
return bounds;
}
export default function (element: HTMLElement, dataset: DOMStringMap) {
const observer = new Observe(element, {
threshold: 0.1,
callback: ({ isIn }) => {
element.classList.toggle("in-view", isIn);
},
});
const track = new Track(element, {
bounds: [0, 1],
callback: (value) => {
element.style.setProperty("--progress", value.toString());
},
});
onDestroy(() => {
observer.destroy();
track.destroy();
});
}
import { onView, onTrack } from "@/modules/_";
export default function (element: HTMLElement, dataset: DOMStringMap) {
// Auto-cleanup observers
const observer = onView(element, {
threshold: 0.1,
callback: ({ isIn }) => {
element.classList.toggle("in-view", isIn);
},
});
const track = onTrack(element, {
bounds: [0, 1],
callback: (value) => {
element.style.setProperty("--progress", value.toString());
},
});
// No manual cleanup needed - handled automatically
}

The ObserverManager automatically groups observers with the same configuration:

// These observers share the same IntersectionObserver instance
const observer1 = new Observe(element1, { threshold: 0.1 });
const observer2 = new Observe(element2, { threshold: 0.1 });
const observer3 = new Observe(element3, { threshold: 0.1 });
// This observer uses a separate instance (different config)
const observer4 = new Observe(element4, { threshold: 0.5 });
export default function (element: HTMLElement, dataset: DOMStringMap) {
const observers: Observe[] = [];
// Create multiple observers
element.querySelectorAll(".animate").forEach((child) => {
const observer = new Observe(child as HTMLElement, {
threshold: 0.1,
callback: ({ isIn }) => {
if (isIn) {
child.classList.add("animated");
}
},
});
observers.push(observer);
});
// Clean up all observers
onDestroy(() => {
observers.forEach((observer) => observer.destroy());
});
}
export default function (element: HTMLElement, dataset: DOMStringMap) {
let track: Track | null = null;
let isActive = false;
// Only track when element is in view
const observer = new Observe(element, {
threshold: 0.1,
callback: ({ isIn }) => {
if (isIn && !isActive) {
// Start tracking
track = new Track(element, {
bounds: [0, 1],
callback: (value) => {
element.style.setProperty("--progress", value.toString());
},
});
isActive = true;
} else if (!isIn && isActive) {
// Stop tracking
track?.destroy();
track = null;
isActive = false;
}
},
});
onDestroy(() => {
observer.destroy();
track?.destroy();
});
}
  1. Threshold Selection: Use appropriate thresholds for your use case
  2. Root Margin: Use rootMargin for early triggering
  3. Direction Detection: Use direction for scroll-based animations
  4. Auto Cleanup: Always clean up observers in onDestroy
  1. Bounds Planning: Plan your bounds carefully for smooth animations
  2. Trigger Points: Choose appropriate trigger points for your effect
  3. Performance: Keep track callbacks lightweight
  4. Responsive: Consider viewport changes in your calculations
  1. Observer Grouping: Let the system group observers automatically
  2. Cleanup: Always destroy observers and tracks
  3. Conditional Creation: Only create observers when needed
  4. Lifecycle Integration: Use lifecycle hooks for automatic cleanup
  • new Observe(element, config) - Create new observer
  • observer.start() - Start observing
  • observer.stop() - Stop observing
  • observer.destroy() - Destroy observer and cleanup
  • new Track(element, config) - Create new track
  • track.value - Current track value
  • track.bounds - Current bounds
  • track.destroy() - Destroy track and cleanup
  • ObserverManager.getInstance() - Get singleton instance
  • manager.addElement(element, config, callbacks) - Add element to observer
  • manager.removeElement(element) - Remove element from observer
  • onView(element, config) - Create auto-cleanup observer
  • onTrack(element, config) - Create auto-cleanup track