A JavaScript Intersection Observer Example

September 2025

I'm working on alice.alanwsmith.com. When it's done, it'll have the full text of Alice's Adventures in Wonderland where every letter gets it's own color and font shape that changes over time.

I've got the page doing what I want, but it's sluggish. I'm not surprised. It's constantly adjusting the size and shape of the entire text. Things smooth out when I remove everything except a single paragraph. I'm thinking I can use Intersection Observer to limit what's being changed to keep things from bogging down.

This page is where I'm experimenting with how to make that happen.

Show Me The Codes

Here's the JavaScript I'm using. It's live and powering the example below let.

JavaScript
function updateStatus(entries, observer) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add(`in-viewport`);
    } else {
      entry.target.classList.remove(`in-viewport`);
    }
  });
}

let options = {
  root: document.querySelector(`.observer-root`),
};

let observer = new IntersectionObserver(updateStatus, options);

document
  .querySelectorAll(`[data-receive="content"]`)
  .forEach((el) => { observer.observe(el); });

The code is pretty much a straight extraction from the MDN guidemdn. It's been slimmed down to remove options that did nothing but set the same values as the defaults.

The only difference between this is what I'm using on the Alice site is that I switched the options.root value from the document.querySelector() to null. Doing that sets the browser's viewport as the boundries for the observer instead of an individual element. The changes happen when things move in and out of the window itself.

More Code, Please

Here's the demo HTML for this page and it's output. A couple quick notes about it.

  • The only thing that's required is the <div class="observer-root"> element and the <p> tags it wraps. The rest of the HTML is only there to shim a simulated viewport for the demo with the ability to see what's scrolled above and below itvisual.
  • The content of the <p> tags is random gibberish that's created when they load in. I do that to make that HTML easier to look at.

HTML

<bitty-1-3
  data-connect="ObserverTest" 
  data-send="content|optionsConfig"
> 
  <div class="example-wrapper">
    <div 
      data-receive="toggleMargin" 
      data-param="above-margin"></div>
    <div class="viewport-note">scrolling viewport</div>
    <div class="above-viewport">above viewport</div>
    <div 
      data-receive="toggleMargin"
      data-param="below-margin"></div>
    <div class="below-viewport">below viewport</div>
  

    <!-- START HERE -->
    <div class="observer-root">
      <div class="shim-only-for-the-example">
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
        <p data-receive="content"></p>
      </div>
    </div>
    <!-- AND, WE'RE DONE -->



  </div>
  <div data-receive="optionsConfig"></div>
</bitty-1-3>

Output

scrolling viewport
above viewport
below viewport

I'm doing a bunch of stuff in the demo to highlight when and where things change, but the CSS comes down to this for the basic functionality to change the text color:

CSS

.in-viewport {
  color: lch(70% 60 120);
}

What Goes Up

Each paragraph has a yellow border at its upper and lower edges. As soon as one of those edges crosses into the viewport the Intersection Observer fires and applies the in-viewport class if any part of the element is inside the viewport.

If the element is completely outside the view port (i.e. both it's yellow lines are above or below the viewport), then the in-viewport class is removed.

A Marginal Upgrade

Because the Alice page works with transitions I need to make sure each paragraph of text starts getting updated before it enters the viewportithink.

I'm doing this by adding a rootMargin to the options setup. That changes this:

let options = {
  root: document.querySelector(`.observer-root`),
};

To this:

let options = {
  root: document.querySelector(`.observer-root`),
  rootMargin: "50px 0px",
};

The values for rootMargin work the same way as CSS marginmargin, but they can only be pixels or percentages. They define how far outside the viewport the observer looks for element edgespx.

Flip on the options.rootMargin: "50px 0px" option in the Configuration Options under the example to see it in practice. (The option also turns on two overlay lines to show where they take effect outside the viewport.)

Back Down The Rabbit-Hole

Now, back to the other site to see if this actually does the thing I think it will. Wish me luck,

-a

Post-Script

If you're interested in the JavaScript and CSS that power the visuals for the example, they're here:

Click Me For The CSS

CSS

.above-margin {
  position: absolute;
  top: 0vh;
  width: 100%;
  height: calc(24vh - 50px);
  border-bottom: 1px solid #ccc;
}

.above-viewport {
  position: absolute;
  top: 0vh;
  width: 100%;
  height: 24vh;
  background-color: rgb(255 255 255 / 0.2);
  writing-mode: sideways-lr;
  text-align: center;
  color: black;
  font-weight: bold;
  font-size: 1.2rem;
  padding: 0.2rem;
  text-decoration: underline;
}

.below-margin {
  position: absolute;
  top: calc(54vh + 50px);
  width: 100%;
  height: calc(26vh - 50px);
  border-top: 1px solid #ccc;
}

.below-viewport {
  position: absolute;
  top: 54vh;
  width: 100%;
  height: 26vh;
  background-color: rgb(255 255 255 / 0.2);
  writing-mode: sideways-lr;
  text-align: center;
  color: black;
  font-weight: bold;
  font-size: 1.2rem;
  padding: 0.2rem;
  text-decoration: underline;
}

.example-wrapper {
  width: 86%;
  border-radius: 0.3rem;
  outline: 1px solid goldenrod;
  height: 80vh;
  position: relative;
  color: lch(70% 40 40 / 0.5);
  margin-top: 1.2rem;
}

.example-wrapper p {
  position: relative;
  border-block: 1px solid goldenrod;
  padding-left: 2.2rem;
}

.observer-root {
  position: absolute;
  top: 24vh;
  left: 0;
  height: 30vh;
  width: 100%;
  border: 1px solid ;
  padding-left: 5rem;
}

.shim-only-for-the-example {
  position: absolute;
  overflow-y: scroll;
  top: -24vh;
  height: 80vh;
  width: calc(100% - 5rem);
  padding-top: 24vh;
  padding-bottom: 26vh;
  overscroll-behavior-y: none;
  padding-right: 10rem;
}


[data-receive="optionsConfig"] {
  border-radius: 0.3rem;
  background-color: rgb(125 125 125 / 0.2);
  width: calc(100% - 5rem);
  margin-top: 0.8rem;
  padding: 1.3rem;
}

.viewport-note {
  position: absolute;
  top: 24vh;
  width: 100%;
  height: 30vh;
  writing-mode: sideways-lr;
  text-align: center;
  color: rgb(200 200 200 / 0.6);
  font-weight: bold;
  font-size: 1.2rem;
  padding: 0.2rem;
  text-decoration: underline;
}

.status-bar {
  position: absolute;
  display: flex;
  justify-content: space-between;
  bottom: 0;
  margin-left: -2rem;
  padding-inline: 0.8rem;
  writing-mode: sideways-lr;
  height: 100%;
  color: black;
  font-weight: bold;
  background-color: red;
}

.is-on {
  background-color: green;
  color: #ccc;
}
Click Me For The JavaScript
JavaScript
const optionsHTML = document.createElement("template");
optionsHTML.innerHTML = `
<h4>Configuration Options</h4>
<div>
  <label>
    <input 
      type="radio" 
      name="control" 
      data-send="switchObserver|toggleMargin"
      data-param="noRootMargin"
      checked>
      <code>options.rootMargin</code> is not set
  </label>
</div>
<div>
  <label>
    <input 
      type="radio" 
      name="control"
      data-send="switchObserver|toggleMargin"
      data-param="showRootMargin">
      <code>options.rootMargin: &quot;50px 0px&quot;</code>
  </label>
</div>`;
const optionsContent = optionsHTML.content;


window.ObserverTest = class {

    bittyInit() {
      console.log("bittyInit component");
    }

    content(_event, el) {
      el.innerHTML = `<div class="status-bar">
        <span class="status">&#8658;</span>
        <span class="status">&#8656;</span>
        </div>
      ${randomText(12, 42)}`;
    }

    optionsConfig(_event, el) {
       el.appendChild(optionsContent.cloneNode(true));
    }

    switchObserver(event, _el) {
      if(event.target.dataset.param === "showRootMargin") {
        options = {
          root: document.querySelector(`.observer-root`),
          rootMargin: "50px 0px",
        };
      } else {
        options = {
          root: document.querySelector(`.observer-root`),
        };
      }

      // Restart the main observer with the new options
      document
        .querySelectorAll(`[data-receive="content"]`)
        .forEach((el) => { observer.unobserve(el); });
      observer = new IntersectionObserver(updateStatus, options);
      document
        .querySelectorAll(`[data-receive="content"]`)
        .forEach((el) => { observer.observe(el); });

      // Restart the highlighting observer with the new options
      document
        .querySelectorAll(`[data-receive="content"]`)
        .forEach((el) => { observerForStatus.unobserve(el); });
      observerForStatus = new IntersectionObserver(pingChangeForStatus, options);
      document
        .querySelectorAll(`[data-receive="content"]`)
        .forEach((el) => { observerForStatus.observe(el); });
    }

    toggleMargin(event, el) {
      if(event.target.dataset.param === "showRootMargin") {
        el.classList.add(el.dataset.param);
      } else {
        el.classList.remove(el.dataset.param);
      }
    }
}

function randomText(min, max) {
  return [...Array(
      Math.floor((Math.random() * (max - min)) + min))
    ]
    .map((x) => {
      return Array(Math.floor(Math.random() * 3) + 3)
        .fill().map((y) => {
          return String.fromCharCode(
            Math.floor(Math.random() * 26) + 97
          )
        }).join("")
    })
    .join(" ");
}

let observerForStatus = new IntersectionObserver(pingChangeForStatus, options);

document
  .querySelectorAll(`[data-receive="content"]`)
  .forEach((el) => { observerForStatus.observe(el); });

function pingChangeForStatus(entries, observer) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.querySelector(".status-bar")
        .classList
        .add('is-on');
    } else {
      entry.target.querySelector(".status-bar")
        .classList
        .remove('is-on');
    }
  });
}

If you're not familiar with bitty, it's what I'm using for the main interactions.

end of line

References

Footnotes

I'm using let for let options = {} and let observer instead of const options = {} and const observer so I and change them for the example. I'd use const if I didn't need to do that.

This is one of those where there's good info but it's surrounded by a ton of extra material that's more theory than practice. The different parts of the code that need to be assembled are separated by entire sections. There's not a single code block with an entire working sample.

I've got plans to set up so I can hide the wrapping code. That'll make the examples cleaner. Just haven't gotten to it yet.

At least, I think I do. Still gotta test exactly how switching things on and off will work.

You could do 50px for every direction, 50px 50px 50px 50px to explicitly set the top, right, bottom, and left edges, or, the 50px 0px format I'm using here that sets the top and bottom to 50 and the sides to 0.