lazy-progressive-image
v1.0.10
Published
Universal progressive image loading component for Web Components and React.
Maintainers
Readme
lazy-progressive-image
A universal progressive image component for Web Components, React, Vue, and Svelte.
It renders a blurred thumbnail first, lazy-loads the full image when visible, and swaps to a fallback icon when the full image is missing or errors.
Features
- Progressive loading with blur thumb -> full image transition
IntersectionObservervisibility trigger with preload margin- Fallback rendering for empty or broken full-size images
- Thumb failure isolation (thumb can fail without hiding full image)
- Universal: vanilla HTML/JS web component + React wrapper
- TypeScript types included
How Lazy Loading Works
- The component mounts a wrapper element and watches it with
IntersectionObserver. - The thumbnail (if provided) renders immediately as a placeholder, independent of visibility — it stays mounted until the full image has actually loaded.
- The full image is not rendered (and not fetched) until the wrapper is near/in view (
rootMargin: 100px) — unless that exactsrchas already finished loading elsewhere on the page during this session, in which case it renders right away without waiting for intersection. - On full image
load, the component callsHTMLImageElement.decode()when available. - Once decode completes (or immediately after
loadon browsers withoutdecode), the full image fades in and the thumbnail fades out via CSS transition.
This means visibility (or a known-loaded cache hit) controls network/render timing, and decode() helps avoid showing a partially decoded full image.
Install
pnpm add lazy-progressive-imageor
npm i lazy-progressive-imageUsage
Vanilla HTML/JS
Import the web component and use it directly in HTML:
<!doctype html>
<html lang="en">
<head>
<script type="importmap">
{
"imports": {
"lit": "https://esm.sh/[email protected]",
"lit/": "https://esm.sh/[email protected]/"
}
}
</script>
<script type="module">
import "lazy-progressive-image";
</script>
</head>
<body>
<lazy-progressive-image
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
root-margin="100px"
></lazy-progressive-image>
</body>
</html>React
The /react export wraps the web component with typed props and React-style event handlers.
import { LazyProgressiveImage } from "lazy-progressive-image/react";
export function Example() {
return (
<LazyProgressiveImage
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
/>
);
}In React 19 and later, you can also use the custom element directly without the wrapper:
import "lazy-progressive-image";
export function Example() {
return (
<lazy-progressive-image
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
root-margin="100px"
></lazy-progressive-image>
);
}Use the wrapper when you want TypeScript JSX types and onLoad/onError props. Use the direct element when you prefer zero abstraction and only need standard attributes.
If you want to reference the React props type explicitly, import it from the React entrypoint:
import {
LazyProgressiveImage,
type LazyProgressiveImageProps,
} from "lazy-progressive-image/react";
const props: LazyProgressiveImageProps = {
src: "https://example.com/full.jpg",
thumbnail: "https://example.com/thumb.jpg",
alt: "Example image",
};Vue
<script setup>
import { LazyProgressiveImage } from "lazy-progressive-image";
</script>
<template>
<lazy-progressive-image
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
root-margin="100px"
></lazy-progressive-image>
</template>Svelte
<script>
import "lazy-progressive-image";
</script>
<lazy-progressive-image
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
root-margin="100px"
></lazy-progressive-image>Listening for image load events
The component dispatches a bubbling, composed image-loaded custom event whenever an image finishes loading. The event detail tells you which image variant loaded and its URL:
| Field | Type | Description |
| ------------- | ----------------------- | -------------------------------------------------------- |
| detail.src | string \| undefined | The URL of the image that loaded (src or thumbnail). |
| detail.type | "full" \| "thumbnail" | Which variant loaded. |
Vanilla HTML/JS
const image = document.querySelector("lazy-progressive-image");
image.addEventListener("image-loaded", (event) => {
const { src, type } = event.detail;
console.log(`${type} image loaded:`, src);
});React
With the React wrapper, use the onLoad prop:
import { LazyProgressiveImage } from "lazy-progressive-image/react";
<LazyProgressiveImage
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
onLoad={(event) => {
const { src, type } = (event as CustomEvent).detail;
console.log(`${type} image loaded:`, src);
}}
/>;When using the custom element directly in React 19, attach the listener via ref:
import "lazy-progressive-image";
<lazy-progressive-image
ref={(el) => {
el?.addEventListener("image-loaded", (e) => {
const { src, type } = (e as CustomEvent).detail;
console.log(`${type} image loaded:`, src);
});
}}
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
></lazy-progressive-image>;Vue
<template>
<lazy-progressive-image
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
@image-loaded="onImageLoaded"
/>
</template>
<script setup>
function onImageLoaded(event) {
const { src, type } = event.detail;
console.log(`${type} image loaded:`, src);
}
</script>Svelte
<lazy-progressive-image
src="https://example.com/full.jpg"
thumbnail="https://example.com/thumb.jpg"
alt="Example image"
on:image-loaded={(event) => {
const { src, type } = event.detail;
console.log(`${type} image loaded:`, src);
}}
/>Styling with CSS custom properties
The component exposes a small set of CSS custom properties so you can override its appearance from a parent page without reaching into its internal shadow DOM.
<style>
lazy-progressive-image {
--lpi-image-object-fit: cover;
--lpi-image-filter: grayscale(1);
--lpi-thumbnail-filter: contrast(1.2) brightness(1.2);
--lpi-thumbnail-blur: 20px;
}
</style>Useful properties include:
--lpi-image-object-fit, and--lpi-image-filterfor the full image--lpi-thumbnail-opacity,--lpi-thumbnail-filter, and--lpi-thumbnail-blurfor the thumbnail--lpi-image-opacity,--lpi-image-transition, and--lpi-thumbnail-transitionfor transition behavior
The styles for the lazy-progressive-image element itself can be overridden with normal CSS properties:
lazy-progressive-image {
display: block;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15);
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
}Note: the component needs a definite height to have somewhere to render the thumbnail placeholder before any image has loaded (the default
:hostsizing is a fixed300px×200px). When overriding size from the consumer, set an explicitheightor anaspect-ratioas shown above —height: autoalone will collapse to 0px until the full image loads, since the placeholder is positioned absolutely and can't establish its own height.
Examples
Example projects for each framework live under examples/:
examples/
├── vanilla/
│ └── index.html
├── react/
│ ├── App.tsx
│ └── package.json
├── vue/
│ └── App.vue
└── svelte/
└── App.svelteTo run the vanilla example:
cd examples/vanilla
npx serve .To run a framework example, follow the package.json scripts inside each directory.
API
Web component attributes
src?: string— full-size image URLthumbnail?: string— blurred thumbnail URLalt?: string— image alt textroot-margin?: string—IntersectionObserverroot margin (default:"100px")
React props
src?: stringthumbnail?: stringalt?: stringrootMargin?: string—IntersectionObserverroot margin (default:"100px")className?: stringstyle?: React.CSSPropertiesonLoad?: (event: Event) => voidonError?: (event: Event) => void
Local Development
pnpm install
pnpm test
pnpm buildTests
This project uses two test runners:
- Unit tests — Vitest with happy-dom for fast logic tests.
- Browser tests —
@web/test-runnerwith Playwright for real browser behavior.
pnpm run test:unit # Vitest
pnpm run test:browser # Playwright/Chromium
pnpm test # bothPublish in a New Repo
- Copy the repository contents into a new git repository root.
- Optionally rename the package in
package.json(name,repository,author). - Run
pnpm install. - Build and test:
pnpm test
pnpm build- Publish:
npm publish --access publicLicense
MIT
