Here’s another quick tip in my Today I Learned series.
Native lazy loading is awesome — just add loading="lazy" and you’re done. By default, images appear abruptly once they’re loaded. No transition; it’s just suddenly there. Depending on the file it could even load from top to bottom. My designers hate that. For a long time, I either relied on JavaScript libraries like Lozad.js or tried to explain why animations weren’t worth the overhead.
This never felt right — it’s just a simple animation! While working on birgidlord.de, I discovered a better solution. Turns out, it was quite easy. Not as easy as I’d like it to be, but more on that further down.
What we’re building
Here’s an example of two lazy-loaded images. The first one appears without an animation while the second one fades in. Reload the page to see the difference. Because the images are quite small (and my server is pretty fast), you probably have to throttle your connection to make it more noticable:


The HTML
Easily enough, we simply add the aforementioned loading attribute to our elements. Also, make sure to always specify image dimensions. Without them, lazy loaded images will cause layout shifts:
<img src="image.jpg" width="800" height="600" loading="lazy">
The CSS
We use a scripting: enabled media query to hide elements only when JavaScript is enabled. This targets our elements with an attribute selector, sets the initial style, and prepares the transition:
@media (scripting: enabled) {
[loading="lazy"] {
opacity: 0;
transition: opacity 0.5s;
}
}
The JavaScript
Here, we iterate over our elements using the same attribute selector and setup an animateIn function. The complete property tells us if an image has finished loading. We need this additional check because images that are already in the browser cache won’t trigger the load event. If we relied on the event listener alone, some images would stay invisible forever 😱
document.querySelectorAll('[loading="lazy"]').forEach(element => {
const animateIn = () => {
element.style.opacity = 1;
};
if (element.complete) {
animateIn();
} else {
element.addEventListener('load', animateIn);
}
});
Why not CSS only?
While researching, I stumbled over this 14 years old Gist talking about a :loaded pseudo class. It’s unfortunately still not a thing so we have to resort to JavaScript for detecting the loaded state. I also expected @starting-style to work, but it doesn’t take into account the loading state.
More complex animations
Since we have to use JavaScript anyways, you could also handle the animation there with libraries like GSAP or Motion. Just make sure you don’t go overboard with the animation and check for prefers-reduced-motion when your animation involves movement. I’d also suggest avoiding lengthy animations that could make your site feel slow.
Replies
-
@medienbaecker wait, are you sure about cached images not firing a load event?
Because I’m pretty sure they do, I’ve been using this technique for a while now and never had an issue with cached images not animating in. 🤔
Didn’t know about the scripting media query though! That one is really cool! 😊
Thank you for sharing. 👍
-
@amxmln I did have issues with relying on just the load event in a project not too long ago. Not sure if that’s a browser specific thing though 🤷♂️
-
@medienbaecker it might have been that the images where already loaded by the time your script kicks in—if they’re cached the load event is emitted instantly from what I could gather.
Since I do process my images, I have onload attributes on them and couldn’t replicate the behaviour you’re describing yet. 🤔
Anyway, I like your solution, too! 😊
-
@amxmln Ahhh, that’s entirely possible! Thanks for sharing 🙌
-
@medienbaecker Cool! But this probably won't work for HTML inserted via JS (e.g. from an API call) after querySelectorAll runs, they will forever have opacity: 0. A more robust and performant solution could be event delegation: Listen to the load event on the body, then checking if it's a relevant element (one with the lazy loading attribute).
Even then you have inline styles, which could be problematic if you want e.g. opacity: 0.5 in CSS until focused.
A CSS pseudoclass :loaded would help! -
@makkabi36 Yeah, such a pseudo class would be amazing. I often put the JavaScript in a function/class that expects a container. On initial load it’s the body, later it’s dynamically added elements or their parents.
-
@medienbaecker TIL!