englishпо-русски
© 2026 — Vladimir Lysov, Chelyabinsk, Russia github dubaua@gmail.com

Why Immerser?

Sometimes designers create complex logic and fix parts of the interface. Also they colour page sections contrasted. How to deal with this mess?

Immerser comes to help you. It’s a javascript library to change fixed elements on scroll.

Immerser is written on typescript. Runtime bundle 9.19Kb gzipped.

Terms

Immerser root — is the parent container for your fixed parts solids. Actually, solids are positioned absolutely to fixed immerser root. The layers are sections of your page. Also you may want to add pager to navigate through layers and indicate active state.

Framework Adapters

Framework adapters are essential for practical use in modern frontend apps. That is why Immerser 6 reworked its core API to support external rendering.

Woo-hoo! React adapter for Immerser is finally released: now Immerser markup can be described with React components instead of manually assembling data-immerser-*. A Vue adapter is also in progress.

Install

Using npm:

Using yarn:

Or if you want to use immerser in browser as global variable:

npm install immerser

yarn add immerser

<script src="https://unpkg.com/immerser@6.1.2/dist/immerser.min.js"></script>

Prepare Your Markup

First, setup fixed container as the immerser root container, and add the data-immerser attribute.

Next place absolutely positioned children into the immerser parent and add data-immerser-solid="solid-id" to each.

Then add data-immerser-layer attribute and a unique id to each section.

You can also add data-immerser-pager-link to navigation links so immerser can mark the active layer in the pager.

<div class="fixed" data-immerser>
  <div class="fixed__pager pager" data-immerser-solid="pager">
    <a href="#reasoning" class="pager__link" data-immerser-synchro-hover="pager-reasoning" data-immerser-pager-link></a>
    <a href="#how-to-use" class="pager__link" data-immerser-synchro-hover="pager-how-to-use" data-immerser-pager-link></a>
    <a href="#how-it-works" class="pager__link" data-immerser-synchro-hover="pager-how-it-works" data-immerser-pager-link></a>
    <a href="#options" class="pager__link" data-immerser-synchro-hover="pager-options" data-immerser-pager-link></a>
    <a href="#recipes" class="pager__link" data-immerser-synchro-hover="pager-recipes" data-immerser-pager-link></a>
  </div>
  <a href="#reasoning" class="fixed__logo logo" data-immerser-solid="logo" data-immerser-synchro-hover="logo">
    immerser
  </a>
  <div class="fixed__menu menu" data-immerser-solid="menu">
    <a href="#reasoning" class="menu__link" data-immerser-synchro-hover="reasoning">Reasoning</a>
    <a href="#how-to-use" class="menu__link" data-immerser-synchro-hover="how-to-use">How to Use</a>
    <a href="#how-it-works" class="menu__link" data-immerser-synchro-hover="how-it-works">How it Works</a>
    <a href="#options" class="menu__link" data-immerser-synchro-hover="options">Options</a>
    <a href="#recipes" class="menu__link" data-immerser-synchro-hover="recipes">Recipes</a>
  </div>
  <div class="fixed__language language" data-immerser-solid="language">
    englishпо-русски
  </div>
  <div class="fixed__about about" data-immerser-solid="about">
    <span>© 2026 — Vladimir Lysov, Chelyabinsk, Russia</span>
    <a href="https://github.com/dubaua/immerser">github</a>
    <a href="mailto:dubaua@gmail.com">dubaua@gmail.com</a>
  </div>
</div>

<div data-immerser-layer id="reasoning"></div>
<div data-immerser-layer id="how-to-use"></div>
<div data-immerser-layer id="how-it-works"></div>
<div data-immerser-layer id="options"></div>
<div data-immerser-layer id="recipes"></div>

Initialize Immerser

Include immerser in your code and create immerser instance with options.

Pass the classname map in the solidClassnamesByLayerId option. The example shows how a layer id maps to a fixed solid id and the classname applied while the solid is over that layer.

// You don't have to import immerser
// if you're using it in browser as global variable
import Immerser from 'immerser';

const immerserInstance = new Immerser({
  solidClassnamesByLayerId: {
    reasoning: {
      logo: 'logo--contrast-lg',
      pager: 'pager--contrast-lg',
      language: 'language--contrast-lg',
    },
    'how-to-use': {
      pager: 'pager--contrast-only-md',
      menu: 'menu--contrast',
      about: 'about--contrast',
    },
    'how-it-works': {
      logo: 'logo--contrast-lg',
      pager: 'pager--contrast-lg',
      language: 'language--contrast-lg',
    },
    options: {
      logo: 'logo--contrast-only-md',
      pager: 'pager--contrast-only-md',
      language: 'language--contrast-only-md',
      menu: 'menu--contrast',
      about: 'about--contrast',
    },
    recipes: {
      logo: 'logo--contrast-lg',
      pager: 'pager--contrast-lg',
      language: 'language--contrast-lg',
    },
  },
  updateLocationHash(layerId) {
    window.history.replaceState(null, '', `#${layerId}`);
  },
  fromViewportWidth: 1024,
  pagerLinkActiveClassname: 'pager__link--active',
  scrollAdjustThreshold: 50,
  scrollAdjustDelay: 600,
  on: {
    init(immerser) {
      // callback on init event
    },
    mount(immerser) {
      // callback on mount event
    },
    unmount(immerser) {
      // callback on unmount event
    },
    destroy(immerser) {
      // callback on destroy event
    },
    structureChange(immerser) {
      // callback on DOM structure change event
    },
    layoutChange(immerser) {
      // callback on layer size recalculation event
    },
    activeLayerChange(activeIndex, immerser) {
      // callback on active layer change event
    },
    layerProgressChange(layerProgressArray, immerser) {
      // callback on layer progress change event
    },
  },
});

Apply styles

Apply colour and background styles to your layers and solids according to your classname configuration passed in options. I’m using BEM methodology in this example.

.fixed {
  position: fixed;
  top: 2em;
  bottom: 3em;
  left: 3em;
  right: 3em;
  z-index: 1;
}
.fixed__pager {
  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(0, -50%);
}
.fixed__logo {
  position: absolute;
  top: 0;
  left: 0;
}
.fixed__menu {
  position: absolute;
  top: 0;
  right: 0;
}
.fixed__language {
  position: absolute;
  bottom: 0;
  left: 0;
}
.fixed__about {
  position: absolute;
  bottom: 0;
  right: 0;
}
.pager,
.logo,
.language,
.about {
  color: black;
}
.pager--contrast,
.logo--contrast,
.menu--contrast,
.language--contrast,
.about--contrast {
  color: white;
}

How it Works

First, immerser finds the root element, layers, solids and masks. Then it measures layers, window and document and calculates the scroll points where each layer enters the viewport, stays there and leaves it.

Core states are calculated once during initialization. While scrolling, immerser only reads the position, schedules redraw with requestAnimationFrame and changes transform to use graphics acceleration.

After that immerser prepares the markup: it creates masks for layers or connects to existing masks, clones solids into each mask and applies the classnames given in configuration. Original solids are temporarily removed, so only masked copies remain visible.

On scroll, immerser moves a mask and its contents in opposite directions. This shows only the part of each solid group that should belong to the current layer. When the active layer changes, immerser updates the pager, can update the location hash and emits events.

When window or container size changes, immerser measures everything again and recalculates positions. If layers change in DOM, call render() to synchronize the structure. If scroll is controlled outside immerser, disable the built-in scroll listener and call syncScroll() manually.

Options

You can pass options to immerser as an object parameter.

Hot options can be changed after mount with updateOptions. Init options are applied only during initialization.

optiontypedefaulthot/initdescription
autoMountbooleantrueinitIf true, constructor mounts immerser immediately
selectorRootunknownundefinedinitParent element used only for selector lookup during mount
solidClassnamesByLayerIdobject{}initNested lookup table: layer id → solid id → CSS class that immerser adds to that solid on that layer. Configuration example is shown above
fromViewportWidthnumber0hotMinimum viewport width in pixels, breakpoint at which immerser mounts
pagerThresholdnumber0.5hotPortion of viewport height that must overlap the next layer before pager switches
updateLocationHashfunctionundefinedhotCallback that receives active layer id when active layer changes. Use it to update location hash or route state
scrollAdjustThresholdnumber0hotPixel threshold near section edges that triggers scroll snapping when exceeded. Pass zero to disable scroll snapping
scrollAdjustDelaynumber600hotDelay in ms before running scroll snapping after user scroll stops
pagerLinkActiveClassnamestringpager-link-activeinitClass for the pager link pointing to the active layer
hasExternalScrollbooleanfalseinitIf true, immerser will not attach its own scroll handler. Intended for use with an external scroll controller and syncScroll calls
hasExternalRendererbooleanfalseinitIf true, skips most DOM mutation routine. Intended for use with render frameworks such as React, Vue.js, and others
debugbooleanfalsehotEnables warning logging
onobject{}initInitial event handlers map keyed by event name

Events

You can subscribe to events via the on option or by calling the on or once method on an immerser instance.

eventargumentsdescription
initimmerser: ImmerserEmitted after initialization
mountimmerser: ImmerserEmitted after immerser mounts and is ready to work
unmountimmerser: ImmerserEmitted after unmount when viewport width is below fromViewportWidth
destroyimmerser: ImmerserEmitted after instance destroy
structureChangeimmerser: ImmerserEmitted after DOM structure synchronization
layoutChangeimmerser: ImmerserEmitted after layer size recalculation changes
activeLayerChangelayerIndex: number, immerser: ImmerserEmitted after active layer changes
layerProgressChangelayerProgressArray: number[], immerser: ImmerserEmitted after layer progress changes
stateChangeimmerser: ImmerserEmitted after any immerser event so external renderers can read current public fields

Public fields and methods

nametypedescription
debugpropertyControls whether immerser reports warnings
mountmethodMounts immerser when viewport width passes the fromViewportWidth breakpoint: discovers DOM, prepares markup, calculates layer sizes, and attaches event listeners
unmountmethodUnmounts immerser: cleans markup owned by immerser and keeps resize handling active for breakpoint remount
updateOptionsmethodUpdates runtime options and applies minimal side effects without remounting
destroymethodFully destroys immerser: unmounts it, removes resize handling, restores original markup, and clears internal state
rendermethodSchedules structure synchronization, calculations, and redraw after DOM mutations
syncScrollmethodSyncs immerser with an externally controlled scroll position. Intended for use with hasExternalScroll=true
onmethodRegisters a persistent immerser event handler
oncemethodRegisters a one-time immerser event handler that is removed after the first call
offmethodRemoves a specific handler for the given immerser event
activeIndexgetterActive layer index derived from scroll position
isMountedgetterIndicates whether immerser is mounted
rootNodegetterRoot DOM element immerser is attached to
layerProgressArraygetterProgress of each layer from 0 (off-screen) to 1 (fully visible)
layerIdsgetterLayer ids in the same order as layers appear in DOM
structureSignaturegetterCurrent structure signature used to detect layer-list changes
layoutSignaturegetterCurrent layout signature used to detect geometry changes
drawSignaturegetterCurrent draw signature used to detect visual state changes

Cloning Event Listeners

Since immerser cloning nested nodes by default, all event listeners and data bound on nodes will be lost after init. Fortunately, you can markup the immerser yourself. It can be useful when you have event listeners on solids, reactive logic or more than classname switching. All you need is to place the number of nested immerser masks equal to the number of the layers. Look how I change the smiley emoji on the right in this page source.

<div class="fixed" data-immerser>
  <div data-immerser-mask>
    <div data-immerser-mask-inner>
      <!-- your markup -->
    </div>
  </div>
  <div data-immerser-mask>
    <div data-immerser-mask-inner>
      <!-- your markup -->
    </div>
  </div>
</div>

Handle Clone Hover

As mentioned above, immerser cloning nested nodes to achieve changing on scroll. Therefore if you hover a partially visible element, only the visible part will change. If you want to synchronize all cloned links, just pass data-immerser-synchro-hover="hoverId" attribute. It will share _hover class between all nodes with this hoverId when the mouse is over one of them. Add _hover selector alongside your :hover pseudoselector to style your interactive elements.

a:hover,
a._hover {
  color: magenta;
}

Handle DOM change

Immerser is not aware of changes in DOM, if you dynamically add or remove nodes. If you change height of the document and want immerser to recalculate and redraw solids, call render method on the immerser instance.

// make any manipulations, that changes DOM flow
document.appendChild(someNode);
document.removeChild(anotherNode);

// then tell immerser redraw things
immerserInstance.render();

External Scroll Engine

If you drive scrolling with a custom scroll engine, for example Locomotive Scroll, disable immerser scroll listener with hasExternalScroll=true flag and call syncScroll method every time the engine updates position. Immerser will only redraw masks without attaching another scroll handler. Keep in mind that immerser will not optimize calls this way, and performance optimization is client responsibility.

import Immerser from 'immerser';

const immerserInstance = new Immerser({
  // turn off immerser scroll handling when using a custom engine
  hasExternalScroll: true,
});

customScrollEngine.on('scroll', () => {
  // subscribe to engine scroll event to run sync immerser
  immerserInstance.syncScroll();
});

External Renderer

Use hasExternalRenderer=true when a rendering framework owns immerser markup. For example, a React wrapper can keep the masks and solids in framework state: immerser-react.

With this flag enabled, immerser does not create masks, clone solids, detach original solids, clean or restore renderer-owned markup, update pager active class, or synchronize hover classes. It still connects existing masks, measures layers, applies required technical styles, updates mask transforms, and emits state events for the external renderer.

AI usage note

The core of the library was written in 2019 and significantly improved in 2022, before AI-assisted programming became a thing. In later iterations, AI was used as a supporting tool for infrastructure tasks, documentation updates, and generation of code generation.

For me, AI is just another tool alongside linters, bundlers, and other means of speeding up and simplifying work. I am lazy, and my laziness pushes me toward inventing better tools.

I use AI openly and consider it important to state this explicitly, because for some people it can be a deciding factor.