# Scroll-Locked Animations

**Last Updated:** 2026-02-16

## Overview

Scroll-locked animations (also known as scroll-hijacking or viewport-locking) are interactive patterns where the viewport temporarily locks when a specific section enters view. During the lock, scroll wheel input accumulates and drives an animation from 0% to 100%. Once complete, the viewport unlocks and normal scrolling resumes.

This pattern creates an immersive, focused experience for key content sections, similar to techniques used on Apple product pages and fullPage.js implementations.

## When to Use

**✅ Appropriate use cases:**

- High-impact content that benefits from focused attention (hero features, key statistics)
- Data visualizations or charts that tell a story progressively
- Product showcases where each feature deserves dedicated viewport time
- Sections where you want to control pacing and ensure content is fully viewed

**❌ Avoid when:**

- Content is informational only (blog posts, documentation)
- Users need quick scanning or skimming capability
- Mobile-first design is critical (scroll-hijacking works best on desktop)
- Accessibility is compromised (always provide escape hatches)
- Section contains interactive elements (forms, buttons) - can interfere with user interaction

## Implementation Pattern

### 1. HTML Structure

**Single-DOM pattern (recommended):** One section, one card. Use CSS `@media` for layout differences; never render two cards (desktop + mobile) in the DOM. Duplicate cards cause layout conflicts when both receive fixed positioning during lock.

```html
<section class="scroll-locked-section" id="unique-section-id">
  <div class="mx-auto max-w-7xl px-6 lg:px-8">
    <h2>Section Title</h2>
    <p>Description</p>
  </div>

  <!-- Single card container - ONE card for desktop and mobile -->
  <div class="scroll-locked-card-container min-h-[600px] flex justify-center items-center">
    <div class="scroll-locked-card">
      <!-- Animated content here - same element for both viewports -->
    </div>
  </div>
</section>
```

**Key principles:**

- **Single card only** – Avoid `.partner-earnings-wrapper` / `.partner-earnings-mobile` split; one card prevents duplication and overlapping when locked
- Min-height on container prevents layout shift
- Desktop: scroll-lock driver; mobile: IntersectionObserver + time-based animation (no locking)
- Card width: fixed `400px` when locked (not `90vw`) to prevent expansion; `max-width: calc(100vw - 2rem)` for small viewports

### 2. JavaScript Implementation

```javascript
(function () {
  'use strict';

  const SECTION_ID = 'unique-section-id';
  const DESKTOP_BREAKPOINT = 640;
  const SCROLL_THRESHOLD = 1200; // Total scroll delta needed (pixels)
  const INTERSECTION_THRESHOLD = 0.5; // Section must be 50% visible

  // State
  let isLocked = false;
  let accumulatedDelta = 0;
  let animationFrame = null;

  // Normalize deltaY across input devices
  function normalizeDelta(e) {
    let delta = e.deltaY;
    // deltaMode: 0 = pixels, 1 = lines, 2 = pages
    if (e.deltaMode === 1) delta *= 16; // Lines to pixels
    else if (e.deltaMode === 2) delta *= window.innerHeight; // Pages to pixels
    return delta;
  }

  // Update animation based on progress
  function updateProgress() {
    const progress = Math.min(Math.max(accumulatedDelta / SCROLL_THRESHOLD, 0), 1);
    updateAnimation(progress);

    // Unlock when complete
    if (progress >= 1 && isLocked) {
      unlockViewport();
      // Optional: scroll to next section
      const nextSection = section.nextElementSibling;
      if (nextSection) {
        setTimeout(() => nextSection.scrollIntoView({ behavior: 'smooth' }), 100);
      }
    }

    // Allow exiting upwards
    if (progress <= 0 && isLocked && accumulatedDelta < 0) {
      unlockViewport();
    }
  }

  // Wheel event handler (passive: false required)
  function onWheel(e) {
    if (!isLocked) return;
    e.preventDefault();
    e.stopPropagation();

    const delta = normalizeDelta(e);
    accumulatedDelta += delta;
    accumulatedDelta = Math.max(-100, Math.min(accumulatedDelta, SCROLL_THRESHOLD));

    if (animationFrame) cancelAnimationFrame(animationFrame);
    animationFrame = requestAnimationFrame(updateProgress);
  }

  // Touch event handler (mobile)
  function onTouchMove(e) {
    if (!isLocked) return;
    e.preventDefault();
  }

  // Keyboard escape hatch (Spacebar to skip)
  function onKeyDown(e) {
    if (isLocked && e.code === 'Space') {
      e.preventDefault();
      accumulatedDelta = SCROLL_THRESHOLD;
      updateProgress();
    }
  }

  // Lock viewport
  function lockViewport() {
    if (isLocked) return;
    isLocked = true;
    accumulatedDelta = 0;

    section.classList.add('scroll-locked-active');
    window.addEventListener('wheel', onWheel, { passive: false });
    window.addEventListener('touchmove', onTouchMove, { passive: false });
    window.addEventListener('keydown', onKeyDown);

    updateAnimation(0); // Initialize at 0%
  }

  // Unlock viewport
  function unlockViewport() {
    if (!isLocked) return;
    isLocked = false;

    section.classList.remove('scroll-locked-active');
    window.removeEventListener('wheel', onWheel);
    window.removeEventListener('touchmove', onTouchMove);
    window.removeEventListener('keydown', onKeyDown);
  }

  // IntersectionObserver to detect section entry
  const observer = new IntersectionObserver(
    (entries) => {
      const entry = entries[0];
      if (entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_THRESHOLD) {
        lockViewport();
      } else if (!entry.isIntersecting && isLocked) {
        unlockViewport();
      }
    },
    { threshold: [0, INTERSECTION_THRESHOLD, 1.0] }
  );

  // Initialize on DOMContentLoaded
  function init() {
    const section = document.getElementById(SECTION_ID);
    if (!section || window.innerWidth < DESKTOP_BREAKPOINT) return;

    // Check for reduced motion preference
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      updateAnimation(1); // Show final state instantly
      return;
    }

    observer.observe(section);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
```

### 3. CSS Styling

```css
/* Base section - prevent head.php overflow from clipping fixed card */
.scroll-locked-section {
  overflow: visible !important;
}

/* Card container - relative positioning */
.scroll-locked-section .scroll-locked-card-container {
  position: relative;
  width: 100%;
}

/* Card base - fixed width when locked, not 90vw */
.scroll-locked-section .scroll-locked-card {
  position: relative;
  width: 100%;
  max-width: min(400px, calc(100% - 2rem));
  margin: 0 auto;
  transition: all 0.3s ease;
}

/* Locked state: fixed 400px card, centered */
.scroll-locked-section.scroll-locked-active .scroll-locked-card {
  position: fixed !important;
  top: 50% !important;
  left: 50% !important;
  transform: translate(-50%, -50%) !important;
  width: 400px !important;
  max-width: calc(100vw - 2rem) !important;
  z-index: 1000 !important;
}
```

**Conflict avoidance:** Scope all rules under `.scroll-locked-section`. Avoid `body` overrides that conflict with `head.php` (overflow-x, max-width). Ensure the section is not inside an `overflow: hidden` parent.

## Best Practices

### 1. Accessibility

**✅ Always implement:**

- **Keyboard escape hatch:** Spacebar to skip animation
- **Reduced motion:** Respect `prefers-reduced-motion` - show final state instantly
- **Screen reader announcements:** Announce lock state ("Animation in progress")
- **Focus management:** Maintain proper focus order when locked
- **Tab navigation:** Provide Tab/Shift+Tab to skip section

**Example reduced motion handling:**

```javascript
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  // Skip lock entirely, show final state
  updateAnimation(1);
  return;
}
```

### 2. Performance

**✅ Optimize for smooth 60fps:**

- **Use `requestAnimationFrame`** for animation updates
- **Use CSS transforms** (not `top`/`left`) for positioning - compositor-optimized
- **Avoid layout thrashing:** Batch DOM reads/writes
- **Don't throttle wheel events** - defeats smoothness (accumulation handles this)

**Example requestAnimationFrame usage:**

```javascript
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(updateProgress);
```

### 3. Mobile Considerations

**✅ Provide fallback:**

- **Never lock viewport on mobile** (`< 640px`)
- Use IntersectionObserver + time-based animation instead
- Test with momentum scrolling (iOS Safari)
- Handle touch events separately from wheel events

**Example mobile fallback:**

```javascript
const io = new IntersectionObserver((entries) => {
  if (!entries[0].isIntersecting) return;
  io.disconnect();
  // Animate from 0% to 100% over 1.5s
  const start = performance.now();
  function step(now) {
    const progress = Math.min((now - start) / 1500, 1);
    updateAnimation(progress);
    if (progress < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}, { threshold: 0.2 });
```

### 4. Browser Compatibility

**✅ Test across:**

- Chrome/Edge (Chromium)
- Firefox
- Safari (macOS and iOS)
- Handle different deltaY values across input devices (trackpad vs mouse)

**Example deltaY normalization:**

```javascript
function normalizeDelta(e) {
  let delta = e.deltaY;
  if (e.deltaMode === 1) delta *= 16; // Lines → pixels
  else if (e.deltaMode === 2) delta *= window.innerHeight; // Pages → pixels
  return delta;
}
```

## Common Pitfalls

### ❌ Pitfall 1: Forgetting `passive: false`

**Problem:** `preventDefault()` doesn't work without `{ passive: false }`.

**Solution:**

```javascript
window.addEventListener('wheel', onWheel, { passive: false }); // ✅ Correct
```

### ❌ Pitfall 2: Not handling reverse scrolling

**Problem:** User can't scroll back up once locked.

**Solution:** Track negative `accumulatedDelta` and unlock when `< 0`:

```javascript
accumulatedDelta = Math.max(-100, Math.min(accumulatedDelta, SCROLL_THRESHOLD));
if (progress <= 0 && isLocked && accumulatedDelta < 0) unlockViewport();
```

### ❌ Pitfall 3: No escape hatch

**Problem:** User is trapped in animation.

**Solution:** Provide keyboard shortcut (Spacebar) to skip:

```javascript
function onKeyDown(e) {
  if (isLocked && e.code === 'Space') {
    e.preventDefault();
    accumulatedDelta = SCROLL_THRESHOLD; // Jump to 100%
    updateProgress();
  }
}
```

### ❌ Pitfall 4: Locking mobile viewport

**Problem:** Scroll-hijacking frustrates mobile users.

**Solution:** Desktop-only pattern (`>= 640px`), provide simpler mobile fallback.

### ❌ Pitfall 5: Ignoring reduced motion

**Problem:** Accessibility violation for users with vestibular disorders.

**Solution:** Always check `prefers-reduced-motion` and skip lock:

```javascript
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  updateAnimation(1); // Final state instantly
  return;
}
```

## Testing Strategy

### Manual Testing Checklist

- [ ] **Basic flow:** Scroll to section → viewport locks → animation progresses → unlock at 100%
- [ ] **Reverse scrolling:** Lock at 50% → scroll up → progress decreases → can exit upwards
- [ ] **Keyboard escape:** Press Spacebar → animation jumps to 100% → unlock
- [ ] **Mobile:** No locking, fallback animation works
- [ ] **Reduced motion:** Preference respected, final state shown instantly
- [ ] **Browser compatibility:** Chrome, Firefox, Safari (desktop + mobile)
- [ ] **Performance:** No jank, maintains 60fps (DevTools Performance panel)

### Automated Testing

```javascript
// Test wheel event accumulation
function testScrollAccumulation() {
  const section = document.getElementById('unique-section-id');
  for (let i = 0; i < 100; i++) {
    section.dispatchEvent(new WheelEvent('wheel', { deltaY: 12 }));
  }
  // Verify progress reaches 100%
}
```

## Examples in Codebase

### Partner Page Earnings Section

**Location:** `v2/pages/static_partner.php`, `v2/includes/partner-first-year-earnings-animation.php`

**Implementation:** Chart animation showing first-year earnings projection (€0 → €17,206) using scroll-locked pattern. **Single card DOM** – one `.partner-earnings-card` for desktop and mobile; no desktop/mobile split to avoid duplication and overlapping.

**Key features:**

- Single card in DOM (no `.partner-earnings-wrapper` / `.partner-earnings-mobile` split)
- IntersectionObserver with 50% threshold
- Normalized deltaY for trackpad/mouse compatibility
- Spacebar escape hatch
- Mobile fallback with time-based animation (1.5s)
- `prefers-reduced-motion` support
- Fixed 400px card width when locked (not 90vw)

**Files:**

- JavaScript: `v2/js/partner-earnings-scroll.js`
- CSS: `v2/css/partner-page.css`
- HTML: `v2/includes/partner-first-year-earnings-animation.php`

## Resources

- **MDN - Wheel Event:** https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event
- **MDN - Intersection Observer:** https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- **Web Accessibility (WCAG):** https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions
- **Apple Product Pages:** Example of polished scroll-hijacking UX

## Related Documentation

- `.cursor/rules/static-pages.mdc` - Static page patterns
- `docs/DOCUMENTATION_STANDARDS.md` - Documentation conventions
- `docs/development/JAVASCRIPT_LOGGING_BEST_PRACTICES.md` - Logging guidelines
