atomicscroll
v1.0.0
Published
Smooth visible-scroll triggers and progressive scroll-driven animations in zero-dependency vanilla JavaScript and TypeScript.
Maintainers
Readme
atomicscroll
Smooth visible-scroll triggers and progressive scroll-driven animations in zero-dependency vanilla JavaScript and TypeScript.
✨ Features
- 🚀 Zero Dependencies — Extremely lightweight (< 15 KB minified), no external runtime dependencies.
- ⚡ Dual Mode Engine:
- Trigger Mode: Uses
IntersectionObserverto trigger style transitions when elements scroll into view. - Progressive Mode: Coalesces passive scroll events via
requestAnimationFrameto interpolate 2D/3D transforms and opacity proportionally to scroll depth.
- Trigger Mode: Uses
- 📐 Pure Anchored Math — Normalized 0 to 1 progress formula anchored cleanly at element entry and exit points.
- 🎨 2D & 3D Transform Interpolation — Full support for
translate,scale,rotate,skew, decimal floating point values, and CSS units (px,deg,%,rem, etc.). - 🔤 CSS Custom Properties — Any
--*variable works as a style key, andvar()references in values are resolved for interpolated properties (transform,opacity,background-position,background-size). - ♿ Built-in Accessibility — Automatically respects
prefers-reduced-motion: reduceby snapping to target styles without animation. - 📦 Universal Bundles — Ships ESM, CommonJS, and IIFE formats with TypeScript
.d.tsdeclaration maps. - 🔌 Framework Agnostic — Native Vanilla JS core with first-class hooks for React, Vue, Svelte, Angular, and more.
⚡ Performance & Device Budgets
Both engines are built for smoothness, but they scale differently. Use this guide to size your animations for the weakest device you support.
How Each Engine Performs
| Engine | Mechanism | Cost Profile |
|---|---|---|
| Trigger (type: 'trigger') | IntersectionObserver viewport detection (off-main-thread) | Near-zero per-frame cost; style writes happen only on enter/leave. Scales to hundreds of elements. |
| Progressive (type: 'progressive') | JS interpolation on scroll frames (passive listeners + requestAnimationFrame coalescing) | Cost scales with how many elements animate simultaneously. Parked offscreen elements cost only cheap layout reads because unchanged styles are never rewritten. |
Device Budgets (Field Estimates)
| Scenario | Guidance | |---|---| | Progressive elements animating simultaneously | Keep to ≤ 3–5 for steady 60fps on budget Android hardware; modern devices handle far more. | | Progressive elements registered but offscreen | Cheap (reads only) — ~30–50 is fine on low-end devices. | | Trigger-mode elements | Effectively unlimited. |
[!NOTE] These figures are estimates based on typical low-end hardware timings and vary with DOM/CSS complexity and other page activity. Validate element-heavy sections on real pages using DevTools CPU throttling before shipping.
Best Practices
- Prefer trigger mode unless you need true scroll-linked interpolation.
- Animate only
transformandopacity— they are compositor-friendly and what the engine interpolates smoothly; other properties snap at progress boundaries. - Spread animated sections down the page instead of clustering many progressive elements inside a single viewport.
- For heavily animated hero sections, add
will-change: transformin your own CSS to hint layer promotion. - Call
.destroy()when removing elements in SPA environments to detach listeners. - Reduced-motion users are handled automatically (
respectReducedMotion), which also skips all per-frame work.
Known Scaling Limits
Progressive mode currently registers one passive scroll listener per instance and re-parses transform strings each frame. This is imperceptible at typical usage but is a documented optimization candidate if you need dozens of simultaneously animating elements on entry-level hardware.
🌐 Browser Support
| Browser | Minimum Version | |---|---| | Chrome / Edge | 80+ | | Firefox | 74+ | | Safari (macOS) | 13.1+ | | Safari (iOS / iPadOS) | 13.1+ |
- Bundles are compiled with an ES2020 build target; browsers that cannot parse modern JS syntax (e.g., IE11) are not supported.
- Trigger mode automatically falls back to passive
scroll/resizevisibility checks whenIntersectionObserveris unavailable. - Honors the
prefers-reduced-motion: reducemedia query where supported. - Need older browsers (e.g., Safari 12, Chrome 60–79)? Lower the esbuild
target(e.g.,'es2017') inbuild.mjsand rebuild — no source changes required.
📦 Installation
# npm
npm install atomicscroll
# yarn
yarn add atomicscroll
# pnpm
pnpm add atomicscrollBrowser CDN / Direct Script Tag
<script src="https://unpkg.com/atomicscroll/dist/atomicscroll.min.js"></script>
<script>
// Global `AtomicScroll` is automatically available
new AtomicScroll('.my-element', {
type: 'trigger',
newStyle: { transform: 'scale(1.1)', opacity: 1 }
});
</script>🚀 Quick Start
1. Trigger Mode (Intersection Trigger)
Triggers target styles once an element enters the viewport.
import { AtomicScroll } from 'atomicscroll';
const scroll = new AtomicScroll('#headline', {
type: 'trigger',
originalStyle: { opacity: 0, transform: 'translate(0px, 40px)' },
newStyle: { opacity: 1, transform: 'translate(0px, 0px)' },
once: true,
threshold: 0.2,
onEnter: (event) => console.log('Element entered viewport:', event.element),
});2. Progressive Mode (Scroll-Driven Interpolation)
Interpolates transforms and opacity continuously as the element traverses the viewport.
import { AtomicScroll } from 'atomicscroll';
const scroll = new AtomicScroll('.hero-card', {
type: 'progressive',
originalStyle: { transform: 'scale(0.8) rotate(-5deg)', opacity: 0.4 },
newStyle: { transform: 'scale(1.2) rotate(5deg)', opacity: 1 },
onProgress: ({ progress, direction }) => {
console.log(`Scroll progress: ${progress.toFixed(2)}, direction: ${direction}`);
},
});3. HTML Data-Attributes API
You can define styles directly in your HTML templates:
<!-- Trigger element -->
<div
class="animate-on-scroll"
data-at-type="trigger"
data-at-to='{"transform":"translate(0px, 0px)","opacity":1}'
>
Hello World
</div>
<!-- Progressive element -->
<div
class="scroll-scale"
data-at-type="progressive"
data-at-from='{"transform":"scale(0.5)","opacity":0.2}'
data-at-to='{"transform":"scale(1)","opacity":1}'
>
Scaling content
</div>// Auto-initializes from data-at-* attributes
new AtomicScroll('.animate-on-scroll');
new AtomicScroll('.scroll-scale');🔤 CSS Variables & Custom Properties
Style maps accept any CSS custom property name — there is no --at- namespace restriction (the --at-* names in the demos are simply the demo design system's token convention).
const scroll = new AtomicScroll('.card', {
type: 'trigger',
originalStyle: { '--card-opacity': '0', opacity: 0 },
newStyle: { '--card-opacity': '1', opacity: 1 },
});var() references inside values are also supported:
<div
class="hero"
data-at-type="progressive"
data-at-from='{"transform":"var(--tf-from)","opacity":"var(--opa-from)"}'
data-at-to='{"transform":"var(--tf-to)","opacity":"var(--opa-to)"}'
>
...
</div>Support depends on the mode and property:
| Property | Trigger mode | Progressive mode |
|---|---|---|
| transform, opacity, background-position, background-size | Applied verbatim; browser resolves var() natively | var() is pre-resolved via computed style, then interpolated numerically per frame |
| All other properties (e.g. color, width) and custom-property keys (--*) | Applied verbatim | Snapped to the target value at progress >= 1, reverted below (values pass through verbatim, including var()) |
Notes:
- Fallbacks are honored for interpolated properties:
var(--missing, 0.4)falls back when the variable is undefined. - Arbitrary properties and custom-property keys are not numerically interpolated — they switch between the from/to values at full progress.
🧩 Framework Integration
React
import { useEffect, useMemo, useRef } from 'react';
import { AtomicScroll } from 'atomicscroll';
export function AnimatedBox() {
const ref = useRef<HTMLDivElement>(null);
// Stable identity matters: a fresh object literal per render would tear
// down and rebuild the instance every render (visible under StrictMode).
const options = useMemo(
() => ({
type: 'progressive' as const,
originalStyle: { transform: 'translateY(50px)', opacity: 0 },
newStyle: { transform: 'translateY(0px)', opacity: 1 },
}),
[],
);
useEffect(() => {
if (!ref.current) return;
const instance = new AtomicScroll(ref.current, options);
return () => instance.destroy();
}, [options]);
return <div ref={ref} className="box">Animated Box</div>;
}A complete runnable demo lives in examples/react/ (npm run demo:react).
Vue 3
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { AtomicScroll } from 'atomicscroll';
const el = ref(null);
let instance = null;
onMounted(() => {
if (el.value) {
instance = new AtomicScroll(el.value, {
type: 'trigger',
newStyle: { opacity: 1, transform: 'scale(1)' },
});
}
});
onUnmounted(() => {
instance?.destroy();
});
</script>
<template>
<div ref="el" class="box">Vue Animated Box</div>
</template>Svelte
<script>
import { onMount } from 'svelte';
import { AtomicScroll } from 'atomicscroll';
let el;
onMount(() => {
const instance = new AtomicScroll(el, {
type: 'progressive',
originalStyle: { transform: 'scale(0.5)' },
newStyle: { transform: 'scale(1)' },
});
return () => instance.destroy();
});
</script>
<div bind:this={el}>Svelte Animated Box</div>⚙️ Options Reference (AtomicScrollOptions)
| Option | Type | Default | Description |
|---|---|---|---|
| type | 'trigger' \| 'progressive' | 'trigger' | Animation engine mode. |
| originalStyle | StyleMap | undefined | Starting styles (falls back to computed CSS if omitted). |
| newStyle | StyleMap | {} | Target styles to apply on trigger or interpolate toward. |
| respectReducedMotion | boolean | true | Snaps immediately to target style when reduced motion is preferred. |
| once | boolean | true | Trigger mode: apply and unobserve on first intersection (false re-arms). |
| threshold | number \| number[] | 0 | IntersectionObserver threshold. |
| rootMargin | string | '0px' | IntersectionObserver rootMargin. |
| observerRoot | Element \| Document \| null | null | IntersectionObserver root element (defaults to viewport). |
| scrollContainer | Element \| null | null | Progressive mode scroll driver (defaults to window). |
| useDataAttributes | boolean | true | Reads data-at-* attributes from elements as configuration. |
| onEnter | (e: ProgressEvent) => void | undefined | Callback fired when element enters viewport / begins progress. |
| onLeave | (e: ProgressEvent) => void | undefined | Callback fired when element leaves viewport / ends progress. |
| onProgress | (e: ProgressEvent) => void | undefined | Callback fired on progress change in progressive mode. |
Instance Methods
| Method | Description |
|---|---|
| instance.update(partialOptions) | Merge new options and re-initialize every element in place. |
| instance.refresh() | Re-snapshot and re-initialize all elements without changing options. Call after external changes that alter resolved CSS custom properties — e.g. theme switches — so var()-driven from/to targets stay accurate. All demo pages wire their theme toggles to this. |
| instance.init() | (Re-)initialize observers; after destroy() this rebuilds them, including re-arming the live reduced-motion listener. |
| instance.destroy() | Tear down all listeners/observers and restore original inline styles. |
Live Reduced Motion
respectReducedMotion is evaluated at init and continuously: a matchMedia('(prefers-reduced-motion: reduce)') change listener re-evaluates every instance when the OS setting flips mid-session (elements snap to their final state, scroll work stops). Destroying an instance removes the listener.
📊 ProgressEvent Reference
Callbacks receive a ProgressEvent object:
interface ProgressEvent {
element: HTMLElement; // The animated element
progress: number; // Normalized progress value (0 to 1)
direction: 1 | -1 | 0; // 1 = scrolling down, -1 = scrolling up, 0 = stationary
type: 'trigger' | 'progressive'; // Observer mode
entry?: IntersectionObserverEntry;// Available in trigger mode IO callbacks
}🔄 Migration from Legacy API (0.x → 1.0.0)
| Legacy (v0.x) | Modern (v1.0.0) |
|---|---|
| AtScrollTriggerStyle('.selector') | new AtomicScroll('.selector', { type: 'trigger' }) |
| AtScrollProgressiveStyle('.selector') | new AtomicScroll('.selector', { type: 'progressive' }) |
| data-at-new-style='{...}' | data-at-to='{...}' |
| data-at-original-style='{...}' | data-at-from='{...}' |
| Implicit function split | data-at-type="trigger \| progressive" |
| parseInt truncate scales (0.5 → 0) | Accurate parseFloat unit-aware interpolation |
🎨 Demo Styling & Assets (Atomic CSS)
The documentation and demo pages use atomic-css, Google Fonts (Plus Jakarta Sans & JetBrains Mono), and self-hosted local media assets (examples/assets/) for presentation, matching the AtomicScroll design system.
examples/atomic.min.cssis a vendored standalone stylesheet (48,425 B) with a sidecar license atexamples/LICENSE.atomic-css.- Media assets (
examples/assets/) are self-hosted CC0 / Pexels licensed media managed viascripts/fetch-assets.mjs; seeexamples/assets/LICENSE.assets.mdfor full per-file attribution and checksums. Typography is loaded via Google Fonts CDN. - The core library itself (
src/) has zero CSS, font, or asset dependencies and ships withsideEffects: false.
🛠️ Development
# Install dependencies
npm install
# Run TypeScript type check
npm run typecheck
# Run linter
npm run lint
# Run unit tests
npm test
# Run tests in watch mode
npm run test:watch
# Build production bundles
npm run build
# Start development server with live watch
npm run dev
# Start local static server
npm run serveDeployment Notes
- All demo pages reference assets with relative paths, so the repository root deploys as-is to static hosts (GitHub Pages project sites, Netlify, etc.) without path rewrites.
dist/is committed because every demo page loads the runtime bundle from it at page load; rebuild withnpm run buildafter changingsrc/.examples/react/bundle.jsis a prebuilt esbuild artifact (npm run demo:react) required by the React demo on static hosts.scripts/serve.mjssendsX-Content-Type-Options: nosniffand a minimal CSP, and serves a styled404.htmlfor missing routes.
📄 License
MIT © 2026 Santosh Kunwar / codersantosh
