Switch Fixed UI Elements on Scroll With immerser.js

Category: Javascript , Recommended | July 1, 2026
Authordubaua
Last UpdateJuly 1, 2026
LicenseMIT
Views1,160 views
Switch Fixed UI Elements on Scroll With immerser.js

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

OptionPurpose
autoMount
Boolean. Default: true. Init only.
Mounts Immerser during construction. Set it to false when markup appears later.
selectorRoot
ParentNode. Default: undefined. Init only.
Scopes the root and layer selectors to one DOM subtree.
solidClassnamesByLayerId
Object. Default: {}. Init only.
Maps each layer ID to solid IDs and the CSS classes that apply inside that layer.
pagerLinkActiveClassname
String. Default: pager-link-active. Init only.
Sets the active class for links marked with data-immerser-pager-link.

Scroll And Pager Options

OptionPurpose
fromViewportWidth
Number. Default: 0. Hot.
Sets the minimum viewport width that mounts the instance. Immerser unmounts below this breakpoint.
pagerThreshold
Number. Default: 0.5. Hot.
Sets how much of the next layer must enter the viewport before the pager changes state.
updateLocationHash
Function. Default: undefined. Hot.
Receives the active layer ID after a layer change. Use it for hash or route updates.
scrollAdjustThreshold
Number. Default: 0. Hot.
Sets the pixel range near a layer edge that starts scroll adjustment. Set 0 to disable it.
scrollAdjustDelay
Number. Default: 600. Hot.
Sets the delay in milliseconds before scroll adjustment runs after scrolling stops.

Framework And Event Options

OptionPurpose
hasExternalScroll
Boolean. Default: false. Init only.
Skips Immerser’s scroll listener. Call syncScroll() from the external scroll engine.
hasExternalRenderer
Boolean. Default: false. Init only.
Leaves mask markup to a framework renderer. Immerser still measures layers and moves existing masks.
debug
Boolean. Default: false. Hot.
Writes runtime warnings to the browser console.
on
Object. 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

MethodUse 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

MethodUse 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

MethodUse 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

EventRuns 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

EventRuns 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 owns data-immerser. Type: HTMLElement or null.
  • layerProgressArray: Returns read-only per-layer progress values from 0 to 1. 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 solidClassnameArray with solidClassnamesByLayerId. Use explicit layer ids as object keys.
  • Remove data-immerser-layer-config. Put layer-to-solid CSS mappings in solidClassnamesByLayerId.
  • Replace isScrollHandled: false with hasExternalScroll: true.
  • Replace hasToUpdateHash: true with an updateLocationHash(layerId) function.
  • Replace bind(), unbind(), and isBound with mount(), unmount(), and isMounted.
  • Replace the bind, unbind, and layersUpdate events with mount, unmount, and layerProgressChange.
  • Move individual callback properties such as onInit and onActiveLayerChange into the on event map.
  • Remove stylesInCSS from 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:

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

You Might Be Interested In:


Leave a Reply