Speed-Adaptive Scroll Animation Library
A lightweight, vanilla JavaScript class that creates smooth, speed-adaptive scroll animations. ScrollAnimator dynamically adjusts animation timing based on scroll velocity, providing a natural and responsive user experience without any external dependencies.
- 🚀 Speed-Adaptive Animations - Automatically adjusts animation duration based on scroll speed
- 🎨 Flexible CSS Injection - Multiple patterns: default template, custom CSS, function-based, or external stylesheets
- ⚡ Performance Optimized - Built-in LCP (Largest Contentful Paint) optimization
- � Event Callbacks - Hooks for transition start and end events
- �🗑️ Self-Removing Animations - One-time animations with automatic cleanup (opt-in)
- 🔧 Highly Configurable - 15+ configuration options for fine-tuned control
- ♻️ Memory Safe - Comprehensive cleanup with
destroy()method for SPA compatibility - 📦 Zero Dependencies - Pure vanilla JavaScript, no external libraries required
- 🎯 IntersectionObserver API - Modern, performant viewport detection
- 🔍 Debug Mode - Visual indicators and console logs for development
- 📱 Responsive - Works seamlessly across all device sizes
Download scroll-animator.js and include it in your project:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Page</title>
<!-- Load ScrollAnimator in <head> -->
<script src="path/to/scroll-animator.js"></script>
<!-- Initialize in <head> -->
<script>
const animator = new ScrollAnimator()
animator.init()
</script>
</head>
<body>
<div class="animate-on-scroll">Content to animate</div>
</body>
</html>import ScrollAnimator from './scroll-animator.js'
const animator = new ScrollAnimator()
animator.init()The ScrollAnimator script and initialization code MUST be placed in the <head> tag, NOT at the bottom of <body>.
ScrollAnimator injects CSS styles dynamically when initialized. These styles define the initial state of animated elements (e.g., opacity: 0, transform: translateY(100px)).
If the script runs AFTER body elements are already rendered:
- Initial CSS states are not applied
- Elements appear in their final state immediately
- Transitions/animations fail to trigger
- You see no animation effect
The CSS must be injected BEFORE the browser renders body elements.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Page</title>
<!-- ✅ CORRECT: Load and initialize in <head> -->
<script src="scroll-animator.js"></script>
<script>
const animator = new ScrollAnimator({
mainClassName: 'fade-in'
})
animator.init()
</script>
</head>
<body>
<div class="fade-in">This will animate correctly</div>
<div class="fade-in">This will also animate</div>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Page</title>
</head>
<body>
<div class="fade-in">This will NOT animate</div>
<div class="fade-in">This will NOT animate</div>
<!-- ❌ WRONG: Script runs after body is rendered -->
<script src="scroll-animator.js"></script>
<script>
const animator = new ScrollAnimator({
mainClassName: 'fade-in'
})
animator.init()
// CSS injected too late - elements already rendered!
</script>
</body>
</html>If you prefer to place scripts at the bottom of <body>, use the customStyles: 'external' option with an external stylesheet:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Page</title>
<!-- External CSS in <head> -->
<style>
.fade-in {
opacity: 0;
transform: translateY(100px);
transition: all 0.8s ease-out;
}
.fade-in.aos-visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div class="fade-in">This will animate correctly</div>
<!-- Script can be at bottom when using external CSS -->
<script src="scroll-animator.js"></script>
<script>
const animator = new ScrollAnimator({
mainClassName: 'fade-in',
customStyles: 'external' // Skip CSS injection
})
animator.init()
</script>
</body>
</html>If your animations aren't triggering, check these common issues:
- Script Placement: Is the script in
<head>or usingcustomStyles: 'external'? - Class Names: Do your HTML elements have the correct class (default:
animate-on-scroll)? - Initialization: Is initialization blocked? (Note:
init()is called automatically onwindow.load, or call it manually for immediate initialization) - Console Errors: Check browser console (F12) for error messages
- CSS Injection: Inspect
<head>- is there a<style>element with your animation CSS? - Viewport: Are elements below the fold? Scroll down to trigger animations
- Browser Support: Does your browser support IntersectionObserver? (IE11 requires polyfill)
Quick Test: Open browser console and check if CSS was injected:
// Should show injected style element
document.querySelector('head style')Automatic Initialization (Recommended)
ScrollAnimator automatically initializes on window.load event. Simply create an instance:
// Create instance - initialization happens automatically on window.load
const animator = new ScrollAnimator()
// That's it! Elements with class "animate-on-scroll" will animate when page loadsManual Initialization (Optional)
For immediate control or early initialization:
// Create instance and initialize immediately
const animator = new ScrollAnimator()
animator.init() // Initialize before window.load
// Useful for dynamic content or SPA scenarios<div class="animate-on-scroll">
<h2>This will fade in and slide up</h2>
<p>When it enters the viewport</p>
</div>
<div class="animate-on-scroll">
<h2>This will also animate</h2>
<p>With a slight delay after the first element</p>
</div>const animator = new ScrollAnimator({
debug: true, // Enable debug mode
mainClassName: 'fade-in', // Custom class name
maxSpeed: 1000, // Slower animations
optimizeLCP: true, // Optimize for performance
observerRootMargin: '0px 0px 100px 0px' // Trigger earlier
})
// Automatic initialization on window.load
// Or call animator.init() for immediate initializationScrollAnimator provides automatic initialization by default, triggered on the window.load event. This means you don't need to call init() manually in most cases.
How Automatic Initialization Works:
- When you create a ScrollAnimator instance, event listeners are attached
- On
document.readyState === 'interactive', the DOM is queried for elements - On
window.loadevent,init()is called automatically if elements exist - Your animations start working without any manual intervention
When to Use Manual init():
Manual initialization is useful in specific scenarios:
-
Early Initialization: Start animations before
window.loadcompletesconst animator = new ScrollAnimator() animator.init() // Start immediately
-
Dynamic Content: Re-initialize after loading content via AJAX
const animator = new ScrollAnimator() // Later, after loading new content: animator.refresh() // Re-query DOM and restart observation
-
SPA Route Changes: Re-initialize when navigating between routes
// React example useEffect(() => { const animator = new ScrollAnimator() animator.init() // Initialize on component mount return () => animator.destroy() // Cleanup on unmount }, [])
-
Testing: Explicit control over initialization timing
const animator = new ScrollAnimator() // Run tests... animator.init() // Initialize when ready
Best Practice: Use automatic initialization for standard websites, and manual init() only when you need explicit control over timing.
| Option | Type | Default | Description |
|---|---|---|---|
debug |
boolean | false |
Enable debug mode with console logs and visual indicators |
mainClassName |
string | 'animate-on-scroll' |
CSS class for elements to animate |
visibleClassName |
string | 'aos-visible' |
CSS class added when element is visible |
modifierClassName |
null|string | null |
Optional modifier class for self-removing animations (see Self-Removal Feature) |
translateYvalue |
number | 100 |
Y-axis translation in pixels (auto-adjusted for custom CSS) |
optimizeLCP |
boolean | false |
Remove animations from above-the-fold content |
maxSpeed |
number | 850 |
Max transition duration (ms) for slow scrolling |
minSpeed |
number | 650 |
Min transition duration (ms) for fast scrolling |
delayMax |
number | 120 |
Maximum delay between animations (ms) |
delayMin |
number | 0 |
Minimum delay between animations (ms) |
delayStackTweak |
number | 0.5 |
Delay multiplier (0-1) for staggered animations |
maxScrollSpeed |
number | 1800 |
Maximum scroll speed threshold (px/s) |
minScrollSpeed |
number | 800 |
Minimum scroll speed threshold (px/s) |
observerRootMargin |
string | '0px 0px 70px 0px' |
IntersectionObserver root margin |
observerThreshold |
number | 0 |
IntersectionObserver threshold (0-1) |
customStyles |
null|string|Function | null |
Custom CSS injection pattern (see Custom CSS section) |
autoDetectTransform |
boolean | true |
Auto-adjust translateYvalue for custom CSS |
onTransitionStart |
Function|null | null |
Callback fired when transition starts |
onTransitionEnd |
Function|null | null |
Callback fired when transition ends |
The self-removal feature allows you to create one-time animations that automatically clean up after completion, returning elements to a pristine state with no residual classes or inline styles.
What is Self-Removal?
When an element has both the mainClassName and modifierClassName, ScrollAnimator will automatically remove all animation-related classes and inline styles after the animation completes. This is perfect for:
- One-time entrance animations
- Landing page hero sections
- Modal/dialog entrance effects
- Elements that should animate once and never again
What Gets Removed:
mainClassName(e.g., 'animate-on-scroll')visibleClassName(e.g., 'aos-visible')modifierClassName(e.g., 'self-remove')- Inline
transitionDurationstyle - Inline
transitionDelaystyle
// Enable self-removal feature
const animator = new ScrollAnimator({
modifierClassName: 'self-remove'
})<!-- Element with self-removal -->
<div class="animate-on-scroll self-remove">
This will animate once, then all classes and styles are removed
</div>
<!-- Normal element (no self-removal) -->
<div class="animate-on-scroll">
This will animate and keep all classes
</div>Before animation:
<div class="animate-on-scroll self-remove">Content</div>During animation:
<div class="animate-on-scroll aos-visible self-remove"
style="transition-duration: 850ms; transition-delay: 120ms;">
Content
</div>After animation completes:
<div>Content</div>Enable the feature:
const animator = new ScrollAnimator({
modifierClassName: 'self-remove' // Any class name you prefer
})Disable the feature (default):
const animator = new ScrollAnimator({
modifierClassName: null // Feature disabled
})1. Landing Page Hero
const animator = new ScrollAnimator({
modifierClassName: 'once'
})<section class="animate-on-scroll once hero">
<h1>Welcome to Our Site</h1>
<!-- Animates once on page load, then cleanup -->
</section>2. Mixed Usage
const animator = new ScrollAnimator({
mainClassName: 'fade-in',
modifierClassName: 'self-remove'
})<!-- Self-removing: animates once -->
<div class="fade-in self-remove">One-time animation</div>
<!-- Persistent: can animate multiple times -->
<div class="fade-in">Repeatable animation</div>3. Modal Entrance
const animator = new ScrollAnimator({
modifierClassName: 'cleanup'
})<dialog class="animate-on-scroll cleanup">
<h2>Modal Content</h2>
<!-- Animates on open, cleanup after -->
</dialog>The self-removal feature handles all edge cases automatically:
1. Instant Reveals
- Elements above viewport (already scrolled past)
- LCP-optimized elements (above-the-fold)
- Both get complete cleanup even with 0ms transitions
2. Multiple Transition Properties
- CSS transitions with multiple properties (opacity, transform)
- Cleanup happens only once despite multiple
transitionendevents
3. Method Interactions
refresh(): Self-removed elements won't be re-queried (nomainClassName)addElements(): Can re-add elements if you manually restoremainClassNamedestroy(): Safely handles already-cleaned elementspause()/resume(): No conflicts with self-removal
Enable debug mode to see self-removal in action:
const animator = new ScrollAnimator({
debug: true,
modifierClassName: 'self-remove'
})Console output:
🗑️ Self-removal: Complete cleanup of element (removed classes: 'animate-on-scroll', 'aos-visible', 'self-remove' + inline styles) <div>...</div>
For instant reveals:
⚡ Instant reveal: Complete cleanup of element (removed classes: 'animate-on-scroll', 'aos-visible', 'self-remove' + inline styles) <div>...</div>
✅ DO:
- Use for one-time entrance animations
- Use for landing page hero sections
- Use for modal/dialog entrances
- Mix self-removing and persistent elements
- Choose descriptive modifier class names ('once', 'cleanup', 'self-remove')
❌ DON'T:
- Use for elements that need to re-animate on scroll
- Use for infinite scroll content
- Rely on classes being present after animation
- Use with elements that need persistent animation state
The self-removal feature is completely opt-in and backward compatible:
- Default:
modifierClassName: null(feature disabled) - Existing code continues to work without changes
- No breaking changes to existing animations
- Elements without modifier class behave exactly as before
ScrollAnimator provides callbacks to hook into the animation lifecycle. This allows you to trigger custom JavaScript logic when animations start or end.
| Callback | Arguments | Description |
|---|---|---|
onTransitionStart |
(element) |
Fired when the element enters the viewport and the transition begins. |
onTransitionEnd |
(element) |
Fired when the CSS transition completes. |
const animator = new ScrollAnimator({
// Fired when animation starts (element becomes visible)
onTransitionStart: (element) => {
console.log('Animation started for:', element)
// Example: Play a sound, trigger analytics, etc.
},
// Fired when animation completes
onTransitionEnd: (element) => {
console.log('Animation ended for:', element)
// Example: Enable interactions, focus input, etc.
element.classList.add('animation-complete')
}
})- Instant Reveals: For elements that are revealed instantly (e.g., above the viewport or LCP optimized), both callbacks are fired immediately in sequence.
- Context: The
elementargument provides access to the DOM element being animated.
The customStyles configuration option controls how ScrollAnimator injects CSS styles into your page. It supports 5 different patterns, each optimized for specific use cases.
What is customStyles?
The customStyles option determines:
- What CSS gets injected into the
<head>tag - When CSS injection happens (or if it's skipped)
- How animation styles are generated
Key Features:
- 🎯 Flexible: 5 patterns from zero-config to fully custom
- ⚡ Automatic: Built-in template with sensible defaults
- 🔧 Type-Safe: Function-based pattern prevents class name mismatches
- 🚀 Performance: Minimal CSS injection, no external dependencies
- 🎨 Creative: Full control over animation styles
| Pattern | Type | CSS Injection | Use Case | Complexity |
|---|---|---|---|---|
| null (default) | Built-in | ✅ Auto | Quick start, default animations | ⭐ Simple |
| 'template' | Built-in | ✅ Auto | Default animations with custom config | ⭐ Simple |
| 'external' | External | ❌ Skip | External stylesheet, CSP compliance | ⭐⭐ Medium |
| String | Custom | ✅ Manual | Simple custom animations | ⭐⭐ Medium |
| Function | Custom | ✅ Dynamic | Complex animations, type-safety | ⭐⭐⭐ Advanced |
Type: null (or omit the option)
When to Use:
- Quick start with zero configuration
- Default fade-up animation is sufficient
- Prototyping or testing
- Learning ScrollAnimator basics
What It Does:
- Uses built-in CSS template
- Injects fade-in + slide-up animation
- Applies
translateYvaluefrom config (default: 100px) - Automatically adjusts timing based on scroll speed
Example:
// Simplest usage - just create instance
const animator = new ScrollAnimator()
// Or explicitly set to null
const animator = new ScrollAnimator({
customStyles: null
})
// Customize config values (template uses them)
const animator = new ScrollAnimator({
customStyles: null,
translateYvalue: 80, // Slide up 80px instead of 100px
maxSpeed: 1000 // Slower animations
})Generated CSS (example):
.animate-on-scroll {
opacity: 0;
transform: translateY(100px);
transition: opacity 850ms ease-out, transform 850ms ease-out;
}
.animate-on-scroll.aos-visible {
opacity: 1;
transform: translateY(0);
}Best For: Beginners, quick prototypes, default animations
Type: 'template' (string keyword)
When to Use:
- You want the default animation style
- But need to customize timing/distance values
- Explicit about using the template
- Same as
nullbut more explicit in code
What It Does:
- Identical to
nullpattern - Uses built-in CSS template
- Respects all config values (
translateYvalue,maxSpeed, etc.) - More explicit in code intent
Example:
// Explicitly use template with custom values
const animator = new ScrollAnimator({
customStyles: 'template',
translateYvalue: 50, // Shorter slide distance
maxSpeed: 1200, // Slower animations
minSpeed: 800
})
// Good for team projects - clear intent
const animator = new ScrollAnimator({
customStyles: 'template', // Explicit: "I want the default template"
mainClassName: 'slide-up',
translateYvalue: 60
})Best For: Teams, explicit code intent, customizing default animation timing
Type: 'external' (string keyword)
When to Use:
- You have animation CSS in external stylesheet
- Content Security Policy (CSP) restrictions prevent inline styles
- You want full control over CSS loading
- Script must be at bottom of
<body>(not in<head>) - Build process handles CSS bundling
What It Does:
- Skips CSS injection entirely
- No
<style>element created - Automatically sets
translateYvalue: 0(unless explicitly set) - Relies on your external CSS file
Example:
// JavaScript
const animator = new ScrollAnimator({
customStyles: 'external',
mainClassName: 'fade-in'
})/* Your external stylesheet (styles.css) */
.fade-in {
opacity: 0;
transition: opacity 0.6s ease;
}
.fade-in.aos-visible {
opacity: 1;
}<!-- HTML -->
<head>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="fade-in">Content</div>
<!-- Script can be at bottom when using external CSS -->
<script src="scroll-animator.js"></script>
<script>
const animator = new ScrollAnimator({
customStyles: 'external'
})
</script>
</body>Important Notes:
⚠️ Script placement: Can be in<body>(not required in<head>)⚠️ translateYvalue: Auto-set to0(set explicitly if using transforms)⚠️ Your responsibility: Ensure CSS is loaded before elements render
Best For: CSP compliance, external stylesheets, build processes, script at bottom
Type: string (CSS as template literal)
When to Use:
- Simple custom animations
- One-off animation styles
- Quick customization without external files
- Prototyping different animation styles
What It Does:
- Injects your custom CSS string into
<head> - No template processing
- Automatically sets
translateYvalue: 0(unless explicitly set) - Full control over CSS content
Examples:
// Simple fade-in only
const animator = new ScrollAnimator({
mainClassName: 'fade-in',
customStyles: `
.fade-in {
opacity: 0;
transition: opacity 0.6s ease;
}
.fade-in.aos-visible {
opacity: 1;
}
`
})
// Slide from left
const animator = new ScrollAnimator({
mainClassName: 'slide-left',
customStyles: `
.slide-left {
opacity: 0;
transform: translateX(-50px);
transition: all 0.8s ease-out;
}
.slide-left.aos-visible {
opacity: 1;
transform: translateX(0);
}
`
})
// Zoom in with rotation
const animator = new ScrollAnimator({
mainClassName: 'zoom-rotate',
customStyles: `
.zoom-rotate {
opacity: 0;
transform: scale(0.8) rotate(-5deg);
transition: all 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.zoom-rotate.aos-visible {
opacity: 1;
transform: scale(1) rotate(0);
}
`
})Limitations:
⚠️ No type-safety: Class names are hardcoded strings⚠️ No config integration: Can't usetranslateYvalueor other config values⚠️ Typo risk: Mismatched class names won't be caught
Best For: Simple animations, prototyping, one-off styles
Type: function (receives config object, returns CSS string)
When to Use:
- Recommended for production use
- Type-safe class name matching
- Dynamic CSS generation based on config
- Multiple instances with different class names
- Complex animations requiring config values
- Team projects requiring maintainability
What It Does:
- Calls your function with config parameters
- Injects returned CSS string into
<head> - Automatically sets
translateYvalue: 0(unless explicitly set) - Prevents class name mismatches
Function Signature:
customStyles: (config) => stringConfig Object Parameters:
mainClassName- Main animation class (e.g., 'animate-on-scroll')visibleClassName- Visible state class (e.g., 'aos-visible')translateYvalue- Slide distance in pixelsmaxSpeed- Maximum animation duration in msminSpeed- Minimum animation duration in ms
Examples:
// Basic function-based CSS (recommended)
const animator = new ScrollAnimator({
mainClassName: 'zoom-in',
customStyles: ({ mainClassName, visibleClassName }) => `
.${mainClassName} {
opacity: 0;
transform: scale(0.8);
transition: all 0.6s ease;
}
.${mainClassName}.${visibleClassName} {
opacity: 1;
transform: scale(1);
}
`
})
// Using all config parameters
const animator = new ScrollAnimator({
mainClassName: 'slide-up',
translateYvalue: 60,
maxSpeed: 1000,
customStyles: ({ mainClassName, visibleClassName, translateYvalue, maxSpeed }) => `
.${mainClassName} {
opacity: 0;
transform: translateY(${translateYvalue}px);
transition: all ${maxSpeed}ms cubic-bezier(0.4, 0, 0.2, 1);
}
.${mainClassName}.${visibleClassName} {
opacity: 1;
transform: translateY(0);
}
`
})
// Complex animation with multiple properties
const animator = new ScrollAnimator({
mainClassName: 'fancy-entrance',
customStyles: ({ mainClassName, visibleClassName, translateYvalue }) => `
.${mainClassName} {
opacity: 0;
transform: translateY(${translateYvalue}px) scale(0.95) rotate(-2deg);
filter: blur(4px);
transition: all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.${mainClassName}.${visibleClassName} {
opacity: 1;
transform: translateY(0) scale(1) rotate(0);
filter: blur(0);
}
`
})
// Reusable animation factory
const createSlideAnimation = (direction) => ({ mainClassName, visibleClassName }) => {
const transforms = {
up: 'translateY(50px)',
down: 'translateY(-50px)',
left: 'translateX(50px)',
right: 'translateX(-50px)'
}
return `
.${mainClassName} {
opacity: 0;
transform: ${transforms[direction]};
transition: all 0.6s ease-out;
}
.${mainClassName}.${visibleClassName} {
opacity: 1;
transform: translate(0, 0);
}
`
}
const animator = new ScrollAnimator({
mainClassName: 'slide-left',
customStyles: createSlideAnimation('left')
})Advantages:
- ✅ Type-safe: Class names always match
- ✅ Dynamic: Uses config values automatically
- ✅ Maintainable: Change class name in one place
- ✅ Reusable: Create animation factories
- ✅ Flexible: Full JavaScript power for CSS generation
Best For: Production code, teams, complex animations, multiple instances
Use this decision guide to select the best customStyles pattern for your needs:
START HERE
↓
Do you need custom animations?
├─ NO → Use Pattern 1 (null) or Pattern 2 ('template')
│ ✅ Quick start, zero config
│
└─ YES → Do you have external CSS file?
├─ YES → Use Pattern 3 ('external')
│ ✅ CSP compliance, external stylesheets
│
└─ NO → Is it a simple one-off animation?
├─ YES → Use Pattern 4 (string)
│ ✅ Quick prototyping
│
└─ NO → Use Pattern 5 (function) ⭐ RECOMMENDED
✅ Production-ready, type-safe, maintainable
Quick Recommendations:
| Scenario | Recommended Pattern | Why |
|---|---|---|
| Just getting started | Pattern 1 (null) | Zero config, works immediately |
| Default animation, custom timing | Pattern 2 ('template') | Explicit intent, custom values |
| CSP restrictions | Pattern 3 ('external') | No inline styles |
| Quick prototype | Pattern 4 (string) | Fast iteration |
| Production code | Pattern 5 (function) ⭐ | Type-safe, maintainable |
| Multiple instances | Pattern 5 (function) ⭐ | Dynamic class names |
| Team project | Pattern 5 (function) ⭐ | Best practices |
The autoDetectTransform option (default: true) automatically adjusts translateYvalue based on your customStyles pattern:
| Pattern | Auto-Adjustment | Reason |
|---|---|---|
| null / 'template' | No change | Template uses translateYvalue |
| 'external' | Set to 0 |
External CSS handles transforms |
| string | Set to 0 |
Custom CSS handles transforms |
| function | Set to 0 |
Function handles transforms |
Example:
// With autoDetectTransform (default)
const animator = new ScrollAnimator({
customStyles: 'external',
translateYvalue: 100 // Will be ignored, auto-set to 0
})
console.log(animator._config.translateYvalue) // 0
// Disable auto-detection
const animator = new ScrollAnimator({
customStyles: 'external',
translateYvalue: 100,
autoDetectTransform: false // Keep translateYvalue: 100
})
console.log(animator._config.translateYvalue) // 100When to Disable autoDetectTransform:
- You explicitly set
translateYvalueand want to keep it - Your custom CSS uses
translateYvaluefrom config - You're debugging transform-related issues
Critical: CSS must be injected before body elements render to prevent flash of unstyled content (FOUC).
Pattern-Specific Timing:
| Pattern | Injection Timing | Script Placement |
|---|---|---|
| null / 'template' / string / function | Constructor (immediate) | Must be in <head> |
| 'external' | No injection | Can be in <body> |
Correct Setup:
<!DOCTYPE html>
<html>
<head>
<!-- Pattern 1-2, 4-5: Script MUST be in <head> -->
<script src="scroll-animator.js"></script>
<script>
const animator = new ScrollAnimator({
customStyles: null // or 'template', string, function
})
</script>
</head>
<body>
<div class="animate-on-scroll">Content</div>
</body>
</html><!DOCTYPE html>
<html>
<head>
<!-- Pattern 3: External CSS loaded first -->
<link rel="stylesheet" href="animations.css">
</head>
<body>
<div class="animate-on-scroll">Content</div>
<!-- Pattern 3: Script can be at bottom -->
<script src="scroll-animator.js"></script>
<script>
const animator = new ScrollAnimator({
customStyles: 'external'
})
</script>
</body>
</html>See Script Placement Requirements for detailed explanation.
Symptoms: Elements don't animate, no visual effect
Solutions:
- Check CSS injection (Patterns 1-2, 4-5):
// Open browser console const styleElement = document.querySelector('head style') console.log(styleElement) // Should exist console.log(styleElement.textContent) // Should contain your CSS
2. **Check script placement** (Patterns 1-2, 4-5):
- Script must be in `<head>`, not `<body>`
- See [Script Placement Requirements](#-critical-script-placement-requirements)
3. **Check external CSS** (Pattern 3):
```javascript
// Verify CSS file is loaded
const styles = getComputedStyle(document.querySelector('.animate-on-scroll'))
console.log(styles.opacity) // Should be '0' initially
Symptoms: CSS exists but elements don't animate
Solutions:
- Use function-based pattern (Pattern 5):
// ❌ BAD: Hardcoded class names (typo risk)
const animator = new ScrollAnimator({
mainClassName: 'fade-in',
customStyles: .fade-inn { /* TYPO! */ }
})
// ✅ GOOD: Function-based (type-safe)
const animator = new ScrollAnimator({
mainClassName: 'fade-in',
customStyles: ({ mainClassName, visibleClassName }) => .${mainClassName} { /* Always correct */ } .${mainClassName}.${visibleClassName} { }
})
2. **Verify class names in HTML**:
```javascript
const elements = document.querySelectorAll('.animate-on-scroll')
console.log(elements.length) // Should match expected count
Symptoms: Custom CSS ignores translateYvalue config
Solution: Use function-based pattern to access config values:
// ❌ BAD: String pattern can't access config
const animator = new ScrollAnimator({
translateYvalue: 80, // This is ignored!
customStyles: `
.animate-on-scroll {
transform: translateY(100px); /* Hardcoded */
}
`
})
// ✅ GOOD: Function pattern uses config
const animator = new ScrollAnimator({
translateYvalue: 80, // This is used!
customStyles: ({ mainClassName, translateYvalue }) => `
.${mainClassName} {
transform: translateY(${translateYvalue}px); /* Dynamic */
}
`
})Symptoms: Console error about CSP violation
Solution: Use Pattern 3 ('external'):
// ❌ BAD: Inline styles blocked by CSP
const animator = new ScrollAnimator({
customStyles: null // Tries to inject <style> element
})
// ✅ GOOD: External CSS (no injection)
const animator = new ScrollAnimator({
customStyles: 'external'
})<!-- Add to your CSP meta tag -->
<meta http-equiv="Content-Security-Policy"
content="style-src 'self'">Symptoms: Animations conflict or override each other
Solution: Use unique class names per instance:
// ❌ BAD: Same class names
const animator1 = new ScrollAnimator({
mainClassName: 'animate-on-scroll' // Conflict!
})
const animator2 = new ScrollAnimator({
mainClassName: 'animate-on-scroll' // Conflict!
})
// ✅ GOOD: Unique class names
const fadeAnimator = new ScrollAnimator({
mainClassName: 'fade-in',
customStyles: ({ mainClassName, visibleClassName }) => `
.${mainClassName} { opacity: 0; }
.${mainClassName}.${visibleClassName} { opacity: 1; }
`
})
const slideAnimator = new ScrollAnimator({
mainClassName: 'slide-up',
customStyles: ({ mainClassName, visibleClassName }) => `
.${mainClassName} { transform: translateY(50px); }
.${mainClassName}.${visibleClassName} { transform: translateY(0); }
`
})- Use Pattern 5 (function) for production - Type-safe and maintainable
- Use Pattern 3 ('external') for CSP compliance - No inline styles
- Use Pattern 1 (null) for quick prototypes - Zero config
- Always use function pattern for multiple instances - Prevents conflicts
- Keep CSS simple - Complex animations can impact performance
- Test on slow devices - Ensure animations don't cause jank
- Respect user preferences - Consider
prefers-reduced-motion
ScrollAnimator provides a comprehensive public API for lifecycle management, element control, and state inspection. All methods support method chaining (return this) except destroy() and getters.
Description: Initializes the animator and starts observing elements.
Returns: ScrollAnimator (for method chaining)
When to Use:
- Manual initialization before
window.load(automatic initialization is default) - SPAs where
window.loadhas already fired - Testing scenarios requiring explicit control
- Dynamic content loaded after page load
What It Does:
- Queries DOM for elements with
mainClassName - Applies LCP optimization if configured
- Reveals elements above viewport if page is scrolled
- Initializes IntersectionObserver
Examples:
// Manual initialization (optional - automatic by default)
const animator = new ScrollAnimator()
animator.init()
// Method chaining
const animator = new ScrollAnimator({ debug: true }).init()
// SPA component mount (React example)
useEffect(() => {
const animator = new ScrollAnimator()
animator.init() // Required in SPAs
return () => animator.destroy()
}, [])Description: Comprehensive cleanup - removes observers, listeners, styles, and classes.
Returns: void
When to Use:
- Component unmount in SPAs (React, Vue, Angular)
- Page navigation in single-page applications
- Cleaning up before re-initializing
- Memory leak prevention
What It Does:
- Disconnects IntersectionObserver
- Removes all event listeners
- Removes injected
<style>element - Removes animation classes from elements
- Clears internal state
Examples:
// Basic cleanup
const animator = new ScrollAnimator()
// ... later
animator.destroy()
// React component cleanup
useEffect(() => {
const animator = new ScrollAnimator()
animator.init()
return () => {
animator.destroy() // Cleanup on unmount
}
}, [])
// Vue component cleanup
onBeforeUnmount(() => {
if (animator) {
animator.destroy()
}
})
// Before page navigation (SPA)
router.beforeEach((to, from, next) => {
animator.destroy()
next()
})Description: Re-queries DOM for elements and restarts observation.
Returns: ScrollAnimator (for method chaining)
When to Use:
- After loading content via AJAX/fetch
- After search/filter updates that change DOM
- After infinite scroll adds new content
- When you don't know which specific elements were added
What It Does:
- Disconnects current observer
- Re-queries DOM for elements with
mainClassName - Restarts observation with new element list
Examples:
// After AJAX content load
const animator = new ScrollAnimator()
async function loadMoreContent() {
const response = await fetch('/api/more-content')
const html = await response.text()
document.getElementById('content').innerHTML += html
animator.refresh() // Re-scan for new elements
}
// After search/filter updates
searchInput.addEventListener('input', async (e) => {
const results = await searchProducts(e.target.value)
resultsContainer.innerHTML = renderResults(results)
animator.refresh() // Observe new results
})
// Infinite scroll
window.addEventListener('scroll', () => {
if (isNearBottom()) {
loadMoreItems().then(() => {
animator.refresh()
})
}
})Description: Temporarily stops observing new elements.
Returns: ScrollAnimator (for method chaining)
When to Use:
- User preference to disable animations
- Performance optimization during heavy operations
- Temporarily disable animations during modal/overlay display
- Accessibility: respect user's motion preferences
What It Does:
- Disconnects IntersectionObserver
- Sets internal paused state
- Existing animations continue, but no new ones trigger
Examples:
// User preference toggle
const animator = new ScrollAnimator()
const toggleBtn = document.getElementById('animationsToggle')
toggleBtn.addEventListener('click', () => {
if (animator.isPaused) {
animator.resume()
toggleBtn.textContent = 'Disable Animations'
} else {
animator.pause()
toggleBtn.textContent = 'Enable Animations'
}
})
// Respect prefers-reduced-motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
if (prefersReducedMotion.matches) {
animator.pause()
}
// Pause during modal display
modal.addEventListener('open', () => animator.pause())
modal.addEventListener('close', () => animator.resume())Description: Resumes observation after pause.
Returns: ScrollAnimator (for method chaining)
When to Use:
- Re-enable animations after user preference change
- Resume after performance-critical operation completes
- Re-enable after modal/overlay closes
What It Does:
- Re-initializes IntersectionObserver
- Clears paused state
- Resumes detecting elements entering viewport
Examples:
// Resume after pause
animator.pause()
// ... later
animator.resume()
// Conditional resume based on user preference
if (userPreferences.enableAnimations) {
animator.resume()
}
// Resume after heavy operation
async function processData() {
animator.pause() // Pause during processing
await heavyDataProcessing()
animator.resume() // Resume after completion
}Description: Adds new elements to observe without re-querying entire DOM.
Parameters:
elements- Single Element, Array of Elements, or NodeList
Returns: ScrollAnimator (for method chaining)
When to Use:
- Adding specific elements dynamically
- More efficient than
refresh()when you know exact elements - Real-time content updates (notifications, chat messages)
- User-generated content
Examples:
// Add single element
const animator = new ScrollAnimator()
const newElement = document.createElement('div')
newElement.classList.add('animate-on-scroll')
document.body.appendChild(newElement)
animator.addElements(newElement)
// Add multiple elements
const newElements = document.querySelectorAll('.dynamic-content')
animator.addElements(newElements)
// Real-time notification
socket.on('notification', (data) => {
const notif = createNotification(data)
notif.classList.add('animate-on-scroll')
container.appendChild(notif)
animator.addElements(notif) // More efficient than refresh()
})
// User adds comment
commentForm.addEventListener('submit', async (e) => {
e.preventDefault()
const comment = await postComment(formData)
const commentEl = createCommentElement(comment)
commentsContainer.appendChild(commentEl)
animator.addElements(commentEl)
})Description: Stops observing specific elements.
Parameters:
elements- Single Element, Array of Elements, or NodeList
Returns: ScrollAnimator (for method chaining)
When to Use:
- Removing elements from observation (elements stay in DOM)
- User deletes content
- Filtering/hiding content
- Performance optimization for off-screen content
Examples:
// Remove single element
const oldElement = document.querySelector('.old-content')
animator.removeElements(oldElement)
// Remove multiple elements
const hiddenElements = document.querySelectorAll('.hidden')
animator.removeElements(hiddenElements)
// User deletes item
deleteBtn.addEventListener('click', () => {
animator.removeElements(itemElement)
itemElement.remove() // Remove from DOM
})
// Filter results
filterBtn.addEventListener('click', () => {
const filtered = items.filter(item => !item.matches(filterCriteria))
animator.removeElements(filtered) // Stop observing
filtered.forEach(item => item.style.display = 'none')
})Description: Returns whether the animator is initialized.
Returns: boolean
When to Use:
- Check initialization state before calling methods
- Conditional logic based on animator state
- Debugging
Examples:
const animator = new ScrollAnimator()
console.log(animator.isInitialized) // false
animator.init()
console.log(animator.isInitialized) // true
// Conditional initialization
if (!animator.isInitialized) {
animator.init()
}
// Safe method calls
if (animator.isInitialized) {
animator.refresh()
}Description: Returns whether the animator is paused.
Returns: boolean
When to Use:
- Check pause state before toggling
- Conditional UI updates
- State-dependent logic
Examples:
const animator = new ScrollAnimator()
console.log(animator.isPaused) // false
animator.pause()
console.log(animator.isPaused) // true
// Toggle pause/resume
if (animator.isPaused) {
animator.resume()
} else {
animator.pause()
}
// Update UI based on state
const statusText = animator.isPaused ? 'Paused' : 'Active'
statusElement.textContent = statusTextDescription: Returns the current transition speed in milliseconds.
Returns: number
When to Use:
- Debugging animation timing
- Displaying current speed to users
- Performance monitoring
Examples:
const animator = new ScrollAnimator()
console.log(`Current speed: ${animator.currentSpeed}ms`)
// Display in debug panel
debugPanel.innerHTML = `
<div>Speed: ${animator.currentSpeed}ms</div>
<div>Elements: ${animator.observedElements.length}</div>
`
// Performance monitoring
setInterval(() => {
console.log('Animation speed:', animator.currentSpeed)
}, 1000)Description: Returns array of currently observed elements.
Returns: Element[] (copy to prevent mutation)
When to Use:
- Check how many elements are being observed
- Iterate over observed elements
- Debugging
- Display statistics
Examples:
const animator = new ScrollAnimator()
console.log(`Observing ${animator.observedElements.length} elements`)
// Check if specific element is observed
const myElement = document.querySelector('.my-element')
const isObserved = animator.observedElements.includes(myElement)
// Display statistics
statsElement.textContent = `
Total elements: ${animator.observedElements.length}
Paused: ${animator.isPaused}
Initialized: ${animator.isInitialized}
`
// Iterate over observed elements
animator.observedElements.forEach(element => {
console.log('Observing:', element.className)
})See comprehensive usage examples in:
- Interactive Demo:
examples/usage-examples.html - Code Examples:
examples/usage-examples.js
Examples include:
- Basic usage patterns
- Lifecycle management
- Element management
- Custom CSS animations
- Multiple instances
- SPA integration (React, Vue)
- Advanced configurations
ScrollAnimator includes comprehensive test suites to ensure reliability and correctness. The tests use a custom framework-agnostic testing framework that works in both Node.js and browsers without external dependencies.
What's Tested: Static utility methods (clamp, lerp, invLerp)
Test Coverage: 26 tests, 100% coverage of utility methods
node tests/scroll-animator.test.js- Create an HTML file:
<!DOCTYPE html>
<html>
<head>
<title>ScrollAnimator Unit Tests</title>
</head>
<body>
<h1>ScrollAnimator Unit Tests</h1>
<div id="test-results"></div>
<script src="../scroll-animator.js"></script>
<script src="scroll-animator.test.js"></script>
</body>
</html>- Open the HTML file in your browser
- Check the console (F12 or Cmd+Option+I) for test results
Success Output:
✓ clamp() should clamp values within range
✓ clamp() should handle min > max
✓ clamp() should handle equal min and max
...
✓ invLerp() should handle edge cases
26/26 tests passed (100%)
All tests completed successfully!
Failure Output (if any test fails):
✓ clamp() should clamp values within range
✗ clamp() should handle min > max
Expected: 5, Got: 10
...
25/26 tests passed (96%)
1 test(s) failed
- Framework: Custom framework-agnostic test framework
- No Dependencies: Works without Jest, Mocha, or other test libraries
- Assertion Methods:
toBe(expected)- Strict equality checktoBeGreaterThan(value)- Greater than comparisontoBeLessThan(value)- Less than comparisontoBeTruthy()- Truthy checktoBeFalsy()- Falsy check.not- Negation modifier
1. clamp(value, min, max) - 9 tests
- Basic clamping within range
- Clamping below minimum
- Clamping above maximum
- Edge cases (min > max, min === max)
- Negative numbers
- Zero values
- Decimal values
2. lerp(start, end, t) - 9 tests
- Linear interpolation at various points
- Start point (t = 0)
- End point (t = 1)
- Midpoint (t = 0.5)
- Negative ranges
- Reverse ranges
- Edge cases
3. invLerp(start, end, value) - 8 tests
- Inverse linear interpolation
- Start point
- End point
- Midpoint
- Values outside range
- Negative ranges
- Edge cases (start === end)
What's Tested: Complete ScrollAnimator lifecycle, custom CSS patterns, and feature integration
Test Coverage: 81 integration tests covering constructor, init, destroy, and advanced features
Important: Integration tests require a browser environment (cannot run in Node.js)
Method 1: Direct Browser Open
- Open
tests/scroll-animator-lifecycle-test.htmlin your browser - Click the "Run Tests" button
- View results in the browser UI and console
Method 2: Local Server (recommended for accurate testing)
# Using Python
python -m http.server 8000
# Using Node.js
npx http-server
# Then open: http://localhost:8000/tests/scroll-animator-lifecycle-test.htmlThe lifecycle integration tests validate the complete lifecycle of ScrollAnimator instances and ensure all features work correctly together.
Test Categories:
1. Constructor Tests (5 tests)
- Default options initialization
- Custom options merging
- Property initialization
- Style injection on construction
- Event listener attachment
2. init() Method Tests (6 tests)
- DOM querying for elements
- Already initialized check (prevents double init)
- No elements warning
- IntersectionObserver creation
- State flags (
_isInitialized) - Method chaining support
3. destroy() Method Tests (5 tests)
- IntersectionObserver disconnection
- Style element removal from DOM
- State reset (
_isInitialized,_isPaused) - NodeList clearing
- Animation class cleanup from elements
4. Lifecycle Flow Tests (7 tests)
- Complete lifecycle (constructor → init → destroy)
- Pause and resume functionality
- Refresh after DOM changes
- Multiple instances coexistence
- Method chaining patterns
addElements()dynamic element additionremoveElements()element removal
5. Event Listener Cleanup Tests (8 tests)
- Scroll event listener removal on destroy
- ReadyStateChange event listener removal
- Load event listener removal
- Proper cleanup verification
- Bound handler reference clearing
- Event listener isolation between instances
- Memory leak prevention
- Complete event cleanup validation
6. Multiple Instances Tests (8 tests)
- Independent operation
- Unique class names
- Different configurations
- Isolated state management
- Separate style elements
- Independent destroy
- No interference between instances
- Concurrent instance coexistence
7. customStyles Validation Tests (9 tests)
- Null value acceptance (default template)
- String value acceptance (custom CSS)
- Function value acceptance (dynamic CSS)
- 'template' keyword validation
- 'external' keyword validation
- Invalid type rejection (number, object, array)
- Empty string handling
- Console warning logging for invalid types
- Type safety enforcement
8. Function-Based customStyles Tests (6 tests)
- Function receives correct config parameters
- Config object structure validation
- Return value injection as CSS
- Template literals with config values
- Multiple instances with different functions
- Function error handling
9. 'external' Keyword customStyles Tests (7 tests)
- CSS injection skipping
- styleElement set to null
- No DOM style element creation
- destroy() method null handling
- Multiple instances with 'external'
- Console.info logging
- Initialization correctness with external CSS
10. translateYvalue Auto-Adjustment Tests (7 tests)
- Auto-adjust to 0 with string customStyles
- Auto-adjust to 0 with function customStyles
- No adjustment when explicitly set by user
- No adjustment with null (default template)
- No adjustment with 'template' keyword
- No adjustment with 'external' keyword
- Console.info logging on auto-adjustment
11. autoDetectTransform Option Tests (6 tests)
- Auto-adjust translateYvalue when true (default)
- No adjustment when false
- User-provided translateYvalue preservation
- Explicit user value handling
- Constructor option setting
- Boolean validation
12. Multiple Instances with Different customStyles Tests (7 tests)
- Null + string pattern independence
- Function + external pattern independence
- Template + function pattern independence
- Three instances with different patterns
- Correct styleElement for each instance
- Independent destroy operations
- Correct translateYvalue per instance (auto-adjusted or user-provided)
Browser UI:
ScrollAnimator Lifecycle Integration Tests
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Constructor: should initialize with default options
✓ Constructor: should merge custom options with defaults
✓ Constructor: should initialize all properties
...
✓ Multiple Instances: should work independently
✓ Element Management: should add elements dynamically
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
81/81 tests passed (100%)
All tests completed successfully!
Console Output:
Note: This is an illustrative example showing test categories.
Actual console output uses a simpler summary format.
[ScrollAnimator Tests] Starting test suite...
✓ Constructor Tests (5/5 passed)
✓ init() Tests (6/6 passed)
✓ destroy() Tests (5/5 passed)
✓ Lifecycle Flow Tests (7/7 passed)
✓ Event Listener Cleanup Tests (8/8 passed)
✓ Multiple Instances Tests (8/8 passed)
✓ customStyles Validation Tests (9/9 passed)
✓ Function-Based customStyles Tests (6/6 passed)
✓ 'external' Keyword Tests (7/7 passed)
✓ translateYvalue Auto-Adjustment Tests (7/7 passed)
✓ autoDetectTransform Option Tests (6/6 passed)
✓ Multiple Instances with Different customStyles Tests (7/7 passed)
Test Summary:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total: 81 tests
Passed: 81 (100%)
Failed: 0
Duration: 456ms
Framework: Custom framework-agnostic test framework
Assertion Methods Available:
toBe(expected)- Strict equality (===)toBeGreaterThan(value)- Greater than comparisontoBeLessThan(value)- Less than comparisontoBeTruthy()- Truthy checktoBeFalsy()- Falsy checktoContain(item)- Array/string contains checktoBeInstanceOf(class)- Instance type check.not- Negation modifier for all assertions
Test Helpers:
setupTestDOM()- Creates test DOM structureteardownTestDOM()- Cleans up test DOMwaitForNextFrame()- Async frame waitingsimulateScroll()- Scroll event simulation
Example Test Structure:
test('should initialize with default options', () => {
const animator = new ScrollAnimator()
expect(animator._config.debug).toBe(false)
expect(animator._config.mainClassName).toBe('animate-on-scroll')
expect(animator._isInitialized).toBeFalsy()
})| Test Suite | Tests | Coverage | Environment |
|---|---|---|---|
| Unit Tests | 26 | 100% of utility methods | Node.js or Browser |
| Integration Tests | 81 | Complete lifecycle & features | Browser only |
| Total | 107 | 100% of public API | Both |
What's Covered:
- ✅ All static utility methods (clamp, lerp, invLerp)
- ✅ Complete lifecycle (constructor, init, destroy)
- ✅ All public API methods (pause, resume, refresh, addElements, removeElements)
- ✅ All getter methods (isInitialized, isPaused, currentSpeed, observedElements)
- ✅ All 5 custom CSS patterns (null, 'template', 'external', string, function)
- ✅ Auto-detection features (autoDetectTransform, translateYvalue adjustment)
- ✅ Multiple instances and isolation
- ✅ Dynamic element management
- ✅ Event listener cleanup
- ✅ Memory leak prevention
Problem: Integration tests fail when run in Node.js
Solution: Integration tests require a browser environment (DOM, IntersectionObserver). Run them in a browser instead:
# Open in browser
open tests/scroll-animator-lifecycle-test.htmlProblem: document is not defined error
Solution: Ensure you're running integration tests in a browser, not Node.js. Unit tests work in both environments.
Problem: Tests pass but animations don't work in your application
Solution:
- Check script placement (must be in
<head>for CSS injection) - Verify class names match between HTML and config
- Check browser console for warnings
- Enable debug mode:
new ScrollAnimator({ debug: true })
Problem: Console errors during test execution
Solution:
- Clear browser cache and reload
- Check for conflicting CSS or JavaScript
- Ensure test file paths are correct
- Try running tests in incognito/private mode
Quick Test Command (for CI/CD):
# Run unit tests
node tests/scroll-animator.test.js
# For integration tests, use a headless browser
# Example with Puppeteer:
npx puppeteer tests/scroll-animator-lifecycle-test.htmlManual Testing Checklist:
- ✅ Run unit tests in Node.js:
node tests/scroll-animator.test.js - ✅ Open integration tests in browser:
tests/scroll-animator-lifecycle-test.html - ✅ Verify 26/26 unit tests pass
- ✅ Verify 81/81 integration tests pass
- ✅ Check console for any warnings
- ✅ Test in multiple browsers (Chrome, Firefox, Safari)
Expected Results:
- ✅ 107/107 tests passing (100%)
- ✅ No console errors
- ✅ All assertions pass
- ✅ Clean test output
- Chrome/Edge 58+
- Firefox 55+
- Safari 12.1+
- Opera 45+
Note: IE11 requires IntersectionObserver polyfill
MIT License - feel free to use in personal and commercial projects.
Jensen SIU
- Website: https://www.jensen-siu.net
Contributions are welcome! Please feel free to submit issues or pull requests.
For questions, issues, or feature requests, please open an issue on the project repository.
Made with ❤️ for smooth scrolling experiences