A lightweight, flexible web component for lazy-loading images based on viewport or container size. Perfect for responsive images that should only load when needed.
Based on the original Easy Lazy Images by Aaron Gustafson, now reimagined as a modern Custom Element.
Performance Benefit: Unlike picture or srcset which always load some image variant, lazy-img can completely skip loading images on screens or containers below your specified threshold. This saves bandwidth and improves performance for users on smaller devices or slower connections.
For example, if you set min-inline-size="768", mobile users will never download that image at all — saving their data and speeding up your page load.
Note on Resize Behavior: Once an image is loaded, it remains loaded even if the viewport or container is resized below the threshold. This is intentional for performance — the component prevents unnecessary downloads, but doesn't unload images that are already in memory. Use the loaded and qualifies attributes to control visibility with CSS if needed.
- Container Queries: Load images based on container width (default)
- Media Queries: Load images based on viewport width
- View-Based Loading: Load images when they enter the viewport using IntersectionObserver
- Named Breakpoints: Support for named breakpoints via CSS custom properties
- Responsive Images: Full support for
srcsetandsizes - Throttled Resize: Efficient resize handling to prevent performance issues
- Event-Driven: Dispatches events when images load
- Zero Dependencies: No external libraries required
- Shadow DOM: Fully encapsulated with CSS custom properties
npm install @aarongustafson/lazy-imgOption 1: Auto-define (easiest)
import '@aarongustafson/lazy-img';Option 2: Manual registration
import { LazyImgElement } from '@aarongustafson/lazy-img/lazy-img.js';
customElements.define('lazy-img', LazyImgElement);Option 3: Both
import { LazyImgElement } from '@aarongustafson/lazy-img';
// Element is registered AND class is available<lazy-img
src="image.jpg"
alt="A beautiful image">
</lazy-img>Load an image when its container reaches a minimum width:
<lazy-img
src="large-image.jpg"
alt="Large image"
min-inline-size="500">
</lazy-img>The image will load when the lazy-img element's container reaches 500px width.
Load an image based on viewport width:
<lazy-img
src="desktop-image.jpg"
alt="Desktop image"
min-inline-size="768"
query="media">
</lazy-img>The image will load when the browser window is at least 768px wide.
Load images when they scroll into view using IntersectionObserver:
<lazy-img
src="image.jpg"
alt="Loads when scrolled into view"
query="view">
</lazy-img>The image will load when it enters the viewport. The default behavior (view-range-start="entry 0%") loads as soon as any part of the image is visible.
Load when 50% visible:
<lazy-img
src="image.jpg"
alt="Loads when half visible"
query="view"
view-range-start="entry 50%">
</lazy-img>Preload before entering viewport:
<lazy-img
src="image.jpg"
alt="Preloads 200px before visible"
query="view"
view-range-start="entry -200px">
</lazy-img>The view-range-start attribute uses scroll-driven animation syntax:
"entry X%"- Load when X% of the element is visible (e.g.,"entry 25%"= 25% visible)"entry -Xpx"- Preload X pixels before entering viewport (e.g.,"entry -300px"= load 300px before visible)
Note: Unlike container or media query modes, view mode doesn't use the qualifies attribute. Images load once when the intersection condition is met and remain loaded.
Use srcset and sizes for responsive images:
<lazy-img
src="image-800.jpg"
srcset="image-400.jpg 400w,
image-800.jpg 800w,
image-1200.jpg 1200w"
sizes="(max-width: 600px) 400px,
(max-width: 1000px) 800px,
1200px"
alt="Responsive image"
min-inline-size="400">
</lazy-img>You can use named breakpoints by defining the --lazy-img-mq CSS custom property:
:root {
--lazy-img-mq: small;
}
@media (min-width: 768px) {
:root {
--lazy-img-mq: medium;
}
}
@media (min-width: 1024px) {
:root {
--lazy-img-mq: large;
}
}<lazy-img
src="image.jpg"
alt="Image with named breakpoints"
named-breakpoints="medium, large"
query="media">
</lazy-img>The image will load when the --lazy-img-mq custom property matches any of the specified breakpoint names.
| Attribute | Type | Default | Description |
|---|---|---|---|
src |
String | - | Required. The image source URL |
alt |
String | "" |
Alternative text for the image |
srcset |
String | - | Responsive image source set |
sizes |
String | - | Responsive image sizes |
width |
String (Number) | - | Intrinsic width of the image (helps prevent layout shift) |
height |
String (Number) | - | Intrinsic height of the image (helps prevent layout shift) |
loading |
String | - | Native lazy loading hint: "lazy" or "eager" |
decoding |
String | - | Image decoding hint: "async", "sync", or "auto" |
fetchpriority |
String | - | Resource fetch priority: "high", "low", or "auto" |
crossorigin |
String | - | CORS settings: "anonymous" or "use-credentials" |
referrerpolicy |
String | - | Referrer policy for the image request |
| Attribute | Type | Default | Description |
|---|---|---|---|
min-inline-size |
String (Number) | - | Minimum inline size in pixels to load the image (ignored in view mode) |
named-breakpoints |
String | - | Comma-separated list of named breakpoints (reads from --lazy-img-mq CSS custom property, ignored in view mode) |
query |
String | "container" |
Query type: "container", "media", or "view" |
view-range-start |
String | "entry 0%" |
When to load in view mode: "entry X%" for threshold or "entry -Xpx" for preload margin |
| Attribute | Type | Description |
|---|---|---|
loaded |
Boolean | Reflects whether the image has been loaded |
qualifies |
Boolean | Reflects whether element currently meets conditions to display (not used in view mode) |
container(default): Uses ResizeObserver to watch the element's container sizemedia: Uses window resize events to watch viewport sizeview: Uses IntersectionObserver to watch when element enters viewport
| Event | Detail | Description |
|---|---|---|
lazy-img:loaded |
{ src: string } |
Fired when the image has loaded |
const lazyImg = document.querySelector('lazy-img');
lazyImg.addEventListener('lazy-img:loaded', (event) => {
console.log('Image loaded:', event.detail.src);
});| Property | Default | Description |
|---|---|---|
--lazy-img-display |
block |
Display mode for the component |
--lazy-img-mq |
- | Current named breakpoint identifier (define on :root with @media queries) |
lazy-img {
--lazy-img-display: inline-block;
}
/* Define named breakpoints */
:root {
--lazy-img-mq: small;
}
@media (min-width: 768px) {
:root {
--lazy-img-mq: medium;
}
}[Recommended for Core Web Vitals]
<lazy-img
src="image.jpg"
alt="A beautiful image"
width="800"
height="600"
min-inline-size="768">
</lazy-img>The width and height attributes help browsers calculate the aspect ratio and reserve space before the image loads, preventing Cumulative Layout Shift (CLS).
<lazy-img
src="hero-image.jpg"
alt="Hero image"
width="1200"
height="600"
fetchpriority="high"
loading="eager">
</lazy-img>Use fetchpriority="high" for above-the-fold images that are critical for Largest Contentful Paint (LCP).
<lazy-img
src="https://cdn.example.com/image.jpg"
alt="CDN image"
crossorigin="anonymous"
min-inline-size="500">
</lazy-img>The crossorigin attribute is necessary when you need to manipulate images from different origins in a canvas.
The loaded and qualifies attributes allow you to control visibility based on current conditions:
/* Hide images that loaded but no longer meet conditions (e.g., after rotation) */
lazy-img[loaded]:not([qualifies]) {
display: none;
}
/* Show a placeholder for images that qualify but haven't loaded yet */
lazy-img[qualifies]:not([loaded])::before {
content: "Loading...";
display: block;
padding: 2em;
background: #f0f0f0;
text-align: center;
}
/* Style images based on their qualification state */
lazy-img[qualifies] {
opacity: 1;
transition: opacity 0.3s;
}
lazy-img:not([qualifies]) {
opacity: 0.5;
}<style>
.sidebar {
container-type: inline-size;
}
</style>
<div class="sidebar">
<lazy-img
src="sidebar-image.jpg"
alt="Sidebar content"
min-inline-size="300">
</lazy-img>
</div>/* Define breakpoints in your CSS */
:root { --lazy-img-mq: small; }
@media (min-width: 768px) { :root { --lazy-img-mq: medium; } }
@media (min-width: 1024px) { :root { --lazy-img-mq: large; } }
@media (min-width: 1440px) { :root { --lazy-img-mq: xlarge; } }<lazy-img
src="portrait.jpg"
alt="Portrait orientation"
named-breakpoints="small, medium"
query="media">
</lazy-img>
<lazy-img
src="landscape.jpg"
alt="Landscape orientation"
named-breakpoints="large, xlarge"
query="media">
</lazy-img>If you don't specify min-inline-size or named-breakpoints, the image loads immediately:
<lazy-img src="image.jpg" alt="Loads immediately"></lazy-img>Note: While this pattern loads the image immediately (like a standard img), it still provides a performance benefit: if JavaScript fails to load or execute, the image won't load at all. This can be desirable for non-critical images that enhance but aren't essential to the content (e.g., decorative images, supplementary graphics, or marketing banners).
Important: Only use this pattern for non-critical images that aren't referenced in your content. Critical images that are part of your content should use standard img tags to ensure they load even when JavaScript is unavailable.
<lazy-img
src="image.jpg"
alt="Container-based"
min-inline-size="400">
</lazy-img><lazy-img
src="image.jpg"
alt="Viewport-based"
min-inline-size="768"
query="media">
</lazy-img><lazy-img
src="image.jpg"
alt="Scroll-based"
query="view"
view-range-start="entry -100px">
</lazy-img>Works in all modern browsers supporting:
- Custom Elements v1
- Shadow DOM v1
- ResizeObserver (for container queries)
- IntersectionObserver (for view mode)
- ES Modules
For legacy browser support, consider polyfills for Custom Elements, ResizeObserver, and IntersectionObserver.
If you're migrating from the original Easy Lazy Images script:
Before:
<div data-image-src="image.jpg"
data-image-alt="Alt text"
data-image-srcset="image-400.jpg 400w, image-800.jpg 800w">
</div>
<script>
window.easyLazyImages(500);
</script>After:
<lazy-img
src="image.jpg"
alt="Alt text"
srcset="image-400.jpg 400w, image-800.jpg 800w"
min-inline-size="500"
query="media">
</lazy-img>Key differences:
- Uses a custom element instead of a global function
- Configuration is per-element via attributes
- Default query type is
container(notmedia) - No longer requires
watchResize()- uses ResizeObserver internally
- Throttled Resize: Resize events are throttled to 150ms to prevent excessive checks
- Shared ResizeObserver: Multiple
lazy-imgelements observing the same parent container share a singleResizeObserverinstance, making it highly efficient for galleries and other scenarios with many images - Shared Window Resize Listener: Multiple
lazy-imgelements using media query mode (query="media") share a single window resize event listener, ensuring optimal performance even with hundreds of instances on a page - Shared IntersectionObserver: Multiple
lazy-imgelements using view mode with the sameview-range-startconfiguration share a singleIntersectionObserver, making scroll-based lazy loading extremely efficient even with hundreds of images - Efficient Loading: Images only render in the DOM after loading conditions are met
- Clean Disconnection: Properly cleans up observers and event listeners when elements are removed; automatically removes unused shared observers and listeners when no longer needed
MIT - See LICENSE
Contributions welcome! See CONTRIBUTING.md
Aaron Gustafson aaron@easy-designs.net (https://www.aaron-gustafson.com/)
Based on my original Easy Lazy Images concept, reimagined as a modern Custom Element.