Change text color with IntersectionObserver when overlapping certain sections

I have a landing page that has several 100% height/width image banners. My default text/border colour is black. When the content moves into some of these sections I want to colour to transition to white.

I saw some of the sections, as it will depend on the colour/contrast of the image. So I thought I’d use a data attribute and IntersectionObserver to detect when to do this.

I found a script on CodePen that changed the background-color using this method and tried to refactor it to fit my needs - which didn’t work.

You can see the example here:

I think found another example on Smashing Magazine that worked great but probably had more code that I needed as the example had a lot more going on with it.

My ‘hacked’ version of this is here:

Hopefully the above demos what I’d like to achieve but in a more simplistic way and I hope someone can help with that as it’s not as bloated.

Originally as the prev/next arrows are centred in the viewport I thought they could change colour when the section hit them, then the header separately but maybe that’s a bit OTT and everything changing at the same time is a bit less jarring?

Hope someone can help me with the cleanest solution - thanks in advance!

Is this doing what you want?

1 Like

Sorry I can’t help with the JS but I did have a similar demo using CSS only and mix-blend mode that was close.

It’s a bit hard to control and you can’t really control the colour as its shows the difference. Works perfect for black and white and does give contrast over images but the color will be the difference to the image.

1 Like

Nice @PaulOB! I really wanted to use mix-blend-mode, I even quite like the effect when it’s not quite a solid colour but with the images I have it threw up some odd colours so had to leave it - really want to use it on a future project though!!

@Archibald thanks, yeah that code is way simpler than mine. As I need to update some other objects (header, nav and text) which don’t all have the same parent, I think I might need to update so it adds a class or data-attr on the elements and apply the styles in the CSS.

Which I’ve tried to do here:

Though I think I might need to change the class to a data- as my real example has a lot of classes on the sections (and not always a ).

Couple of questions…

  • If I want to add the class to a few elements which don’t have the same parent. How could I extend const fixed = document.querySelector(".header"); I’ve tried using a single class with querySelectorAll but then it breaks.

  • I’m not saying I want to do this but if I did want the .header to change when the section hits the top of the browser and the nav when it’s in the centre (as it currently) does, would I need to duplicate all the code then just change the relevant rootMargin for each?

Thanks again for all your help!

EDIT just thinking on the first point. Maybe then it’d be easier to just add the class to the body then style each of the 3 things from there… :upside_down_face:

That js is flawed and it would need to be like this.

const fixed = document.querySelector(".header");

const callback = (entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
    } else {

const options = { threshold: 0.5 };
const observer = new IntersectionObserver(callback, options);

const sections = document.querySelectorAll(".fullbleed");
sections.forEach((s) => observer.observe(s));

However I don;'t see that working as well as my mix blend mode because you aren’t actually detecting when a background changes on each piece of the jigsaw.

You are just making a blanket change half way through the scroll and changing all colours in that section even though some elements are not in the new background. You would need to detect/monitor every single element and detect when the background is over them. Even then you will get text cut off because the background at some stage may be half over the element and in a black/white scenario the text will disappear in one half.

The same happens in the demo by @Archibald if you change the colours to black and white.

Mix-blend mode is the only way to keep the text different to the background or otherwise you have to control the backgrounds very carefully so you never get a white background over white text for example. If you are using images for the background then I don’t believe you will be able to control that.

You may need to re-think :slight_smile:

Thanks @PaulOB! I think the only thing I notice with that code is that it doesn’t work on load / you need to scroll down/up to get the first section to work.

Thought I’d sorted that here…

But looks like it’s intermittent/doesn’t always update the first item when I refresh.

I agree with mix-blend-mode but I actually need something like this for the thing I’m working on. Wish I could use just CSS! The demo probably isn’t helping either as it lacks context but it’s quite a lo-fi/mono theme so the text just fading from black to white should be ok :crossed_fingers:

I think you’d need to add an active class to the element as well because the class on the header is getting put on and then taken off straight away because other entries aren’t intersecting.

Maybe like this:

Of course you still will have the issue of when to trigger the threshold change.

You can use classList.toggle and make use of it’s second parameter ‘force’

classList.toggle(classname, force)

force Optional
If included, turns the toggle into a one way-only operation. If set to false, then token will only be removed, but not added. If set to true, then token will only be added, but not removed.

So the above callback can be refactored like this.

const callback = (entries) => {
  entries.forEach((entry) => {
    fixed.classList.toggle("invert", entry.isIntersecting)
1 Like

Can that be refactored into the last codepen of mine?

The problem is that the class is added to the fixed element and not the scrolling element so effectively the fixed class is added and then taken straight away because other entries are not intersecting. In the above example I also added a class to the scrolling element to avoid that issue :slight_smile:

Just for fun I updated the CSS example with a few images for testing :slight_smile:

Like this Paul?

entries.forEach((entry) => {
  const { target, isIntersecting } = entry;
  fixed.classList.toggle("invert", isIntersecting);
  target.classList.toggle("active", isIntersecting);

1 Like

That’s still adding the class and then taking it away leaving the first item without a class.

If you check my codepen it adds the class to the first one so the header text is white. Your one leaves the first panel dark.

I’m not sure if my logic is correct but it does what I think it should be doing :slight_smile:

For reference, I took by original example from this Smashing Magazine article. Seemed more than I needed to do but thought it might give some context.

In my original ‘hacked’ version based from that, I liked how the header offset worked so it only changed when the section was closer to the header but for some reason it doesn’t what to play ball with my last attempt.

That might come in useful though as I think I’ll end up with two IntersectionObserver scripts. One for the header and another for the prev/next arrows so they can change at different times.

One of the reasons I couldn’t use blend-mix-mode was due to the images I have as backgrounds for .fullbleed the text turns orange/red. So I decided just toggling between black/white was best …hence the PITA! :sweat_smile:

Sorry, I’m confused Paul — Not at my sharpest.

If I run your codepen side by side with mine, visually I am seeing the same results.

This from the inspector, scrolling downwards from the top at each change

1 Like

Look at each page before you start scrolling.

Yours is dark text but mine is light text. Once you start scrolling yours eventually starts behaving :slight_smile:

1 Like

Yes but what happens when the image has black and white sections? You are going to lose the text altogether in some places. You also have the issue of guessing when to add the class (which is done by the threshold amount in the JS) but that doesn’t take into account that some text is already covered and some is not.

I don’t really see that as a viable approach. You’d have to track every single piece of text and check when the background is over it and then change its color. If you have only a few items then I guess that is possible but the one class change approach you have now isn’t going to cut it.

Unless the user isn’t scrolling manually and you are sliding those backgrounds automatically up in a split second?

That’s how it works in my last demo and you just adjust the offset value in the js options. You can control when you want to make the change (within limits).

const options = { threshold: 0.4 };

Yes you are right.

When the observer calls the callback, we only need to set the inverted state once depending on whether there is an intersection found. The mistake with my code is that it was setting or un-setting the inverted state on each iteration of the entries loop. true, false, false ends up as false

If your version is simpler to understand, I would say stick with that! (Keep it simple)

These were a couple of alternatives, I did come up with though.

const isInverted = entries.reduce((isInverted, entry) => {
  const { target, isIntersecting } = entry;
  target.classList.toggle("active", isIntersecting);

  return isInverted || isIntersecting
}, false /* start with false */);

fixed.classList.toggle("invert", isInverted)

We pass a value of false initially to isInverted inside the reduce callback function.

On each iteration isInverted is returned or if isIntersecting is true that will be returned instead and this will be the new value for isInverted. At the end of the reduction isInverted’s final value will be returned.

Or a simpler option with a forEach loop using a similar principle

let isInverted = false

entries.forEach((entry) => {
  const { target, isIntersecting } = entry;
  target.classList.toggle("active", isIntersecting);

  if (isIntersecting) {
    isInverted = true

fixed.classList.toggle("invert", isInverted) 

Hopefully this now works as intended :slight_smile:

1 Like

What have I started haha! Traveling to day but will try to take a look this evening!

I think the one thing I might need to add was in an earlier example where it checks the class (or data-attr) is dark before toggling as .fullbleed could have the dark value or not.

Basically the page will be white, so header can have black text by default but some hero banners / fullbleed image blocks will have ‘dark’ set if the text needs to be inverted. Like if the image was very light it wouldn’t need to change.

So I’ll see if I can incorporate that from the earlier versions later :crossed_fingers:


This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.