
immerser.js is a Vanilla JavaScript library that switches CSS classes on fixed interface elements as users move through named page sections.
Fixed logos, menus, pagers, and utility controls can change state as a long-form page moves between contrasting scenes.
Inspired by the jQuery Midnight.js plugin.
Features:
- Applies a CSS class map to fixed UI elements for each marked page layer.
- Tracks the active layer and normalized visibility progress for every layer.
- Updates manual pager links through a configurable active-state class.
- Supports breakpoint-based mounting and optional scroll-edge adjustment.
- Connects to external scroll controllers and renderer-owned markup when a framework manages the page.
Use Cases:
- Fixed logos and menus stay readable as product-story sections alternate between light and dark artwork.
- Manual pager links reflect the active scene in portfolios, campaign pages, and editorial presentations.
- Documentation pages keep persistent navigation in sync with contrasting chapter backgrounds.
- Locomotive Scroll integrations call
syncScroll()after each scroll update.
How To Use It:
Install or Load immerser.js
Install the package wih NPM
npm install immerser
Import the constructor from the package entry.
import Immerser from 'immerser';
Or load the browser build before creating an instance.
<script src="https://unpkg.com/immerser/dist/immerser.min.js"></script>
Add The Fixed UI And Page Layers
Add data-immerser to the fixed root. Each source solid must sit directly inside that root and needs a unique data-immerser-solid value. Mark each scroll section with data-immerser-layer and an explicit id.
<div class="story-ui" data-immerser>
<a class="story-ui__brand" href="#opening" data-immerser-solid="brand">
Northwind
</a>
<nav class="story-ui__pager" aria-label="Story sections" data-immerser-solid="pager">
<a href="#opening" data-immerser-pager-link>01</a>
<a href="#product" data-immerser-pager-link>02</a>
<a href="#contact" data-immerser-pager-link>03</a>
</nav>
</div>
<main>
<section id="opening" class="story-section story-section--light" data-immerser-layer>
<h2>Opening</h2>
</section>
<section id="product" class="story-section story-section--dark" data-immerser-layer>
<h2>Product</h2>
</section>
<section id="contact" class="story-section story-section--light" data-immerser-layer>
<h2>Contact</h2>
</section>
</main>Write The Fixed Layout And State Classes
.story-ui {
position: fixed;
inset: 2rem;
z-index: 20;
}
.story-ui__brand,
.story-ui__pager {
position: absolute;
color: #111;
}
.story-ui__brand {
top: 0;
left: 0;
}
.story-ui__pager {
top: 50%;
left: 0;
transform: translateY(-50%);
}
.story-ui__brand.is-light,
.story-ui__pager.is-light {
color: #fff;
}
.story-ui__pager a.is-active {
text-decoration: underline;
}
.story-section {
min-height: 100vh;
}
.story-section--dark {
background: #111;
}Initialize the Instance
solidClassnamesByLayerId maps a layer id to the CSS classes that belong on each solid while that layer is active.
const immerser = new Immerser({
solidClassnamesByLayerId: {
opening: {
brand: 'is-light',
pager: 'is-light'
},
product: {},
contact: {
brand: 'is-light',
pager: 'is-light'
}
},
pagerLinkActiveClassname: 'is-active'
});The constructor mounts the instance by default. It creates or connects mask markup, measures the marked layers, and updates the fixed solids as scroll position changes.
Runtime And Server Rendering
Create the instance only after a browser DOM exists. Server-rendered apps should initialize Immerser from a client-side lifecycle hook or another browser-only entry point.
Available Options:
const immerser = new Immerser({
autoMount: true,
selectorRoot: undefined,
solidClassnamesByLayerId: {},
fromViewportWidth: 0,
pagerThreshold: 0.5,
updateLocationHash: undefined,
scrollAdjustThreshold: 0,
scrollAdjustDelay: 600,
pagerLinkActiveClassname: 'pager-link-active',
hasExternalScroll: false,
hasExternalRenderer: false,
debug: false,
on: {}
});Mount And Markup Options
| Option | Purpose |
|---|---|
autoMountBoolean. Default: true. Init only. | Mounts Immerser during construction. Set it to false when markup appears later. |
selectorRootParentNode. Default: undefined. Init only. | Scopes the root and layer selectors to one DOM subtree. |
solidClassnamesByLayerIdObject. Default: {}. Init only. | Maps each layer ID to solid IDs and the CSS classes that apply inside that layer. |
pagerLinkActiveClassnameString. Default: pager-link-active. Init only. | Sets the active class for links marked with data-immerser-pager-link. |
Scroll And Pager Options
| Option | Purpose |
|---|---|
fromViewportWidthNumber. Default: 0. Hot. | Sets the minimum viewport width that mounts the instance. Immerser unmounts below this breakpoint. |
pagerThresholdNumber. Default: 0.5. Hot. | Sets how much of the next layer must enter the viewport before the pager changes state. |
updateLocationHashFunction. Default: undefined. Hot. | Receives the active layer ID after a layer change. Use it for hash or route updates. |
scrollAdjustThresholdNumber. Default: 0. Hot. | Sets the pixel range near a layer edge that starts scroll adjustment. Set 0 to disable it. |
scrollAdjustDelayNumber. Default: 600. Hot. | Sets the delay in milliseconds before scroll adjustment runs after scrolling stops. |
Framework And Event Options
| Option | Purpose |
|---|---|
hasExternalScrollBoolean. Default: false. Init only. | Skips Immerser’s scroll listener. Call syncScroll() from the external scroll engine. |
hasExternalRendererBoolean. Default: false. Init only. | Leaves mask markup to a framework renderer. Immerser still measures layers and moves existing masks. |
debugBoolean. Default: false. Hot. | Writes runtime warnings to the browser console. |
onObject. Default: {}. Init only. | Registers initial event handlers with event names as object keys. |
Instance Methods:
Use lifecycle methods for component cleanup, render() after DOM-flow changes, and syncScroll() when another library owns scrolling.
Mount And Cleanup
| Method | Use It When |
|---|---|
mount() | Markup exists and the instance was created with autoMount: false. |
unmount() | You need to stop Immerser temporarily and keep the instance available for a later remount. |
destroy() | The component leaves the page permanently. This restores Immerser-owned markup and removes resize handling. |
Refresh And Runtime Updates
| Method | Use It When |
|---|---|
render() | Code adds, removes, reorders, or resizes layers and changes the document flow. |
syncScroll() | An external scroll engine reports position changes. Set hasExternalScroll: true first. |
updateOptions(options) | Runtime values need to change. Supported keys: debug, fromViewportWidth, pagerThreshold, updateLocationHash, scrollAdjustDelay, and scrollAdjustThreshold. |
Event Subscription
| Method | Use It When |
|---|---|
on(eventName, handler) | One handler should run every time an Immerser event fires. |
once(eventName, handler) | A handler should run for the next matching event only. |
off(eventName, handler) | You need to remove a handler that was registered with on() or once(). |
Events and Hooks:
Pass initial handlers through the on option. Add or remove handlers later with on(), once(), and off().
const immerser = new Immerser({
on: {
activeLayerChange(activeIndex, instance) {
document.body.dataset.activeLayer = instance.layerIds[activeIndex];
}
}
});Lifecycle Events
| Event | Runs When |
|---|---|
init(immerser) | The constructor finishes initialization. |
mount(immerser) | Immerser mounts, prepares its markup, and starts tracking layers. |
unmount(immerser) | Immerser unmounts after code calls unmount() or the viewport falls below fromViewportWidth. |
destroy(immerser) | Code destroys the instance. |
Layer And Render State Events
| Event | Runs When |
|---|---|
structureChange(immerser) | Immerser synchronizes the layer structure after mount or render(). |
layoutChange(immerser) | Layer dimensions or positions change after a layout calculation. |
activeLayerChange(activeIndex, immerser) | Scrolling selects a different active layer. |
layerProgressChange(layerProgressArray, immerser) | A redraw produces new visibility-progress values for one or more layers. |
stateChange(immerser) | Any other Immerser event completes. External renderers can read the current public fields here. |
Public Instance State:
debug: Controls warning reporting. Type: Boolean.activeIndex: Returns the current active layer index from scroll position. Type: Number.isMounted: Reports if Immerser is currently mounted. Type: Boolean.rootNode: Returns the element that ownsdata-immerser. Type: HTMLElement ornull.layerProgressArray: Returns read-only per-layer progress values from0to1. Type: Read-only Number array.layerIds: Returns layer ids in document order. Type: Read-only String array.structureSignature: Returns the current signature for the marked layer list. Type: String.layoutSignature: Returns the current signature for measured layer geometry. Type: String.drawSignature: Returns the current signature for the rendered scroll state. Type: String.
v6 Migration Guide:
- Replace
solidClassnameArraywithsolidClassnamesByLayerId. Use explicit layer ids as object keys. - Remove
data-immerser-layer-config. Put layer-to-solid CSS mappings insolidClassnamesByLayerId. - Replace
isScrollHandled: falsewithhasExternalScroll: true. - Replace
hasToUpdateHash: truewith anupdateLocationHash(layerId)function. - Replace
bind(),unbind(), andisBoundwithmount(),unmount(), andisMounted. - Replace the
bind,unbind, andlayersUpdateevents withmount,unmount, andlayerProgressChange. - Move individual callback properties such as
onInitandonActiveLayerChangeinto theonevent map. - Remove
stylesInCSSfrom older initialization snippets. Current v6 configuration does not use that option.
More Examples:
Update The Location Hash
const immerser = new Immerser({
solidClassnamesByLayerId: {
overview: { brand: 'is-light' },
pricing: {},
contact: { brand: 'is-light' }
},
updateLocationHash(layerId) {
history.replaceState(null, '', `#${layerId}`);
}
});Use updateLocationHash when the active layer should appear in the address bar without hard-coding hash behavior into the library configuration.
Refresh After Adding A Layer
const story = document.querySelector('#story');
story.insertAdjacentHTML('beforeend', `
<section id="case-study" class="story-section" data-immerser-layer>
<h2>Case Study</h2>
</section>
`);
immerser.render();Call render() after a DOM change that adds, removes, or reorders layers. The method updates the structure, layout measurements, and rendered state.
Connect An External Scroll Controller
const immerser = new Immerser({
hasExternalScroll: true,
solidClassnamesByLayerId: {
intro: { brand: 'is-light' },
details: {}
}
});
scrollController.on('scroll', () => {
immerser.syncScroll();
});Let the external controller own scroll events. Call syncScroll() from its update hook so Immerser redraws against the current position.
Alternatives And Related Resources:
- jQuery Plugin To Create Fixed Navigation As You Scroll – Midnight.js
- jQuery Plugin For One Page Navigation Plugin – Page Scroll To ID
- Scroll-Driven Animation Library for React and Next.js – Kino
FAQs:
Q: Does immerser.js require jQuery?
A: No. Immerser is a dependency-free JavaScript library. Midnight.js is a separate jQuery plugin that inspired the scroll-based fixed UI pattern.
Q: Why do no fixed elements appear after initialization?
A: Add position: fixed and the required placement styles in CSS. The root needs data-immerser, every solid needs data-immerser-solid, and every layer needs data-immerser-layer plus a unique id.
Q: Can I keep nested wrappers around a solid element?
A: Keep source solids as direct children of the data-immerser root. Move arbitrary root children outside that controlled container, or use external-renderer markup when a framework owns the masks.
Q: When should I call render()?
A: Call it after code changes the layer list or changes document flow enough to alter the measured page structure. A normal scroll or resize does not need a manual render() call.
Q: Can a server-rendered Node.js app instantiate Immerser on the server?
A: No. Create the instance after the browser DOM is available. Keep server rendering focused on the markup and start Immerser in client-side code.
Changelog:
v6.0.0 (01/28/2026)
- Added & Renamed API names.
v5.1.0 (01/28/2026)
- update
v3.1.0 (01/12/2022)
- add remote scroll handler option
v3.0.0 (03/02/2021)
- fix immerser didn’t bound without pager
v2.0.3 (05/11/2020)
- fix immerser didn’t bound without pager
v2.0.1 (01/20/2020)
- add mergeOptions, fix bug with scrollAdjust, fix wrong install instructions
v2.0.0 (09/25/2019)
- translation fixes, update, remove rubbish, bump major version







