Exclusifbêta
Blur Up <mv-blur-up>
Enveloppe une <img> native (lazy loading, srcset, sizes, alt et <picture> intacts) : garde son ratio pour que rien ne bouge, affiche une minuscule image floutée, une couleur dominante ou un dégradé, puis passe en fondu à la photo une fois décodée. Ce que d’autres ratent : une image en cache apparaît instantanément, sans fondu ni flash, une image en échec reste une boîte discrète avec son texte alternatif, jamais une icône cassée, et les images ajoutées plus tard ou remplacées gardent le traitement.
| Catégorie | Médias |
|---|---|
| Type | Web Component (<mv-blur-up>) |
| Statut | bêta |
| Keywords | exclusive, light, image, lazy-loading, placeholder, blur-up, lqip, progressive, layout-shift, cls |
When to use
- Product grids and listing cards show many photos that should hold their space and appear smoothly as they load
- A blog or magazine article has large inline photos and the page must not jump while they download
- User-uploaded or CDN images may fail and the card should degrade to a quiet box that still shows the alt text
- Images arrive later or change (infinite scroll, color swatches, client-side routing) and need the same treatment
Avoid when
- The whole card or section is loading, text included, not only its image → use Skeleton instead
- The image is the page's main hero (LCP): load it eagerly with fetchpriority="high" and skip the fade
Installation
node scripts/add.mjs blur-up --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["blur-up"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/element.js, core/motion.js, components/blur-up/blur-up.js, components/blur-up/blur-up.css.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-blur-up color="#c9b8a6"><img src="photo.jpg" width="1200" height="800" alt="…" loading="lazy"></mv-blur-up>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<div id="mv-blur-up-demo" style="display:grid;gap:1rem;width:100%;max-width:1040px;margin-inline:auto">
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem">
<div>
<h3 style="margin:0;font-size:1.05rem">Stays for your fall trip</h3>
<p style="margin:.15rem 0 0;font-size:.8rem;color:var(--mv-fg-muted)">Cached images appear at once. Replay to watch them arrive over a slow connection.</p>
</div>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="mv-blur-up-demo-replay">Replay slow connection</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,210px),1fr));gap:1.25rem">
<article style="display:grid;gap:.35rem;align-content:start">
<mv-blur-up placeholder="data:image/webp;base64,UklGRlgAAABXRUJQVlA4IEwAAADwAQCdASoQAAwAAsBMJbACdADwHvFFAAAA/uq9FTlhAYVr9CpNv+3fa637vG69meSpV8Pb+fWc/9jv4Q4sVfmFSB68LDC/ejCofoAA" style="border-radius:var(--mv-radius-lg);margin-bottom:.3rem">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600' preserveAspectRatio='xMidYMid slice'%3E%3Cdefs%3E%3ClinearGradient id='s' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%23f6c56b'/%3E%3Cstop offset='.55' stop-color='%23f39a4b'/%3E%3Cstop offset='1' stop-color='%23e3643a'/%3E%3C/linearGradient%3E%3CradialGradient id='g'%3E%3Cstop offset='0' stop-color='%23fff1c9' stop-opacity='.9'/%3E%3Cstop offset='1' stop-color='%23fff1c9' stop-opacity='0'/%3E%3C/radialGradient%3E%3C/defs%3E%3Crect width='800' height='600' fill='url(%23s)'/%3E%3Ccircle cx='400' cy='250' r='270' fill='url(%23g)'/%3E%3Ccircle cx='400' cy='250' r='90' fill='%23fff1c9'/%3E%3Cpath d='M0 600L0 360L0 397L50 366L100 370L150 354L200 351L250 370L300 347L350 329L400 325L450 298L500 308L550 344L600 355L650 388L700 408L750 386L800 387L800 600Z' fill='%23c4552f' opacity='.92'/%3E%3Cpath d='M0 600L0 420L0 469L50 470L100 453L150 449L200 418L250 398L300 408L350 400L400 403L450 414L500 392L550 384L600 392L650 387L700 414L750 447L800 450L800 600Z' fill='%238f3423'/%3E%3Cpath d='M0 600L0 490L0 464L50 476L100 474L150 473L200 484L250 469L300 462L350 475L400 475L450 495L500 523L550 521L600 524L650 520L700 495L750 492L800 494L800 600Z' fill='%235a1f18'/%3E%3C/svg%3E" width="800" height="600" alt="Terracotta rooftops of Alfama glowing under a low sun" loading="lazy">
</mv-blur-up>
<div style="display:flex;justify-content:space-between;gap:.5rem;font-size:.9rem"><strong>Casa Alfama</strong><span>$184 <span style="color:var(--mv-fg-muted)">night</span></span></div>
<div style="color:var(--mv-fg-muted);font-size:.8rem">Lisbon, Portugal · Oct 2-6</div>
<div style="font:.7rem/1.4 var(--mv-font-mono);color:var(--mv-fg-subtle)">placeholder · 96-byte WebP</div>
</article>
<article style="display:grid;gap:.35rem;align-content:start">
<mv-blur-up color="#ba7083" style="border-radius:var(--mv-radius-lg);margin-bottom:.3rem">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600' preserveAspectRatio='xMidYMid slice'%3E%3Cdefs%3E%3ClinearGradient id='s' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%232a1b3d'/%3E%3Cstop offset='.55' stop-color='%23b85c7a'/%3E%3Cstop offset='1' stop-color='%23f2a88a'/%3E%3C/linearGradient%3E%3CradialGradient id='g'%3E%3Cstop offset='0' stop-color='%23ffe2c2' stop-opacity='.9'/%3E%3Cstop offset='1' stop-color='%23ffe2c2' stop-opacity='0'/%3E%3C/radialGradient%3E%3C/defs%3E%3Crect width='800' height='600' fill='url(%23s)'/%3E%3Ccircle cx='560' cy='330' r='210' fill='url(%23g)'/%3E%3Ccircle cx='560' cy='330' r='70' fill='%23ffe2c2'/%3E%3Cpath d='M0 600L0 360L0 396L50 400L100 376L150 385L200 382L250 375L300 402L350 394L400 369L450 361L500 322L550 298L600 315L650 316L700 335L750 369L800 358L800 600Z' fill='%235b2e55' opacity='.92'/%3E%3Cpath d='M0 600L0 420L0 456L50 423L100 412L150 420L200 407L250 414L300 417L350 388L400 384L450 383L500 376L550 408L600 434L650 440L700 462L750 455L800 433L800 600Z' fill='%233a1f40'/%3E%3Cpath d='M0 600L0 490L0 446L50 461L100 466L150 481L200 504L250 498L300 498L350 505L400 495L450 506L500 523L550 515L600 517L650 507L700 475L750 467L800 462L800 600Z' fill='%231c1026'/%3E%3Crect y='520' width='800' height='80' fill='%23241430'/%3E%3Crect x='500' y='528' width='120' height='3' fill='%23ffe2c2' opacity='.5'/%3E%3Crect x='526' y='542' width='68' height='2' fill='%23ffe2c2' opacity='.35'/%3E%3C/svg%3E" width="800" height="600" alt="Hills above Kyoto at dusk, seen from a ryokan terrace" loading="lazy">
</mv-blur-up>
<div style="display:flex;justify-content:space-between;gap:.5rem;font-size:.9rem"><strong>Ryokan Hanaya</strong><span>$236 <span style="color:var(--mv-fg-muted)">night</span></span></div>
<div style="color:var(--mv-fg-muted);font-size:.8rem">Kyoto, Japan · Oct 14-18</div>
<div style="font:.7rem/1.4 var(--mv-font-mono);color:var(--mv-fg-subtle)">color · dominant color</div>
</article>
<article style="display:grid;gap:.35rem;align-content:start">
<mv-blur-up gradient="linear-gradient(#123a60, #3f7fb0 55%, #1d3c57)" style="border-radius:var(--mv-radius-lg);margin-bottom:.3rem">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600' preserveAspectRatio='xMidYMid slice'%3E%3Cdefs%3E%3ClinearGradient id='s' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%230f2a4a'/%3E%3Cstop offset='.55' stop-color='%233f7fb0'/%3E%3Cstop offset='1' stop-color='%23bfe0ee'/%3E%3C/linearGradient%3E%3CradialGradient id='g'%3E%3Cstop offset='0' stop-color='%23f4fbff' stop-opacity='.9'/%3E%3Cstop offset='1' stop-color='%23f4fbff' stop-opacity='0'/%3E%3C/radialGradient%3E%3C/defs%3E%3Crect width='800' height='600' fill='url(%23s)'/%3E%3Ccircle cx='220' cy='180' r='120' fill='url(%23g)'/%3E%3Ccircle cx='220' cy='180' r='40' fill='%23f4fbff'/%3E%3Cpath d='M0 600L0 360L0 369L50 360L100 358L150 340L200 357L250 357L300 328L350 327L400 313L450 301L500 338L550 361L600 378L650 412L700 401L750 383L800 390L800 600Z' fill='%232e5877' opacity='.92'/%3E%3Cpath d='M0 600L0 352L0 320L67 328L133 342L200 371L267 369L333 375L400 356L467 369L533 364L600 371L667 348L733 332L800 318L800 600Z' fill='%23fff' opacity='.18'/%3E%3Cpath d='M0 600L0 420L0 439L50 426L100 430L150 417L200 382L250 379L300 375L350 374L400 410L450 427L500 429L550 446L600 435L650 425L700 444L750 444L800 450L800 600Z' fill='%231d3c57'/%3E%3Cpath d='M0 600L0 490L0 496L50 513L100 516L150 531L200 526L250 500L300 493L350 476L400 463L450 478L500 477L550 473L600 481L650 466L700 460L750 478L800 484L800 600Z' fill='%230f2233'/%3E%3Crect y='520' width='800' height='80' fill='%23123049'/%3E%3Crect x='160' y='528' width='120' height='3' fill='%23f4fbff' opacity='.5'/%3E%3Crect x='186' y='542' width='68' height='2' fill='%23f4fbff' opacity='.35'/%3E%3C/svg%3E" width="800" height="600" alt="Fjord and snowy ridges outside the Solberg cabin" loading="lazy">
</mv-blur-up>
<div style="display:flex;justify-content:space-between;gap:.5rem;font-size:.9rem"><strong>Solberg Cabin</strong><span>$158 <span style="color:var(--mv-fg-muted)">night</span></span></div>
<div style="color:var(--mv-fg-muted);font-size:.8rem">Tromsø, Norway · Nov 3-7</div>
<div style="font:.7rem/1.4 var(--mv-font-mono);color:var(--mv-fg-subtle)">gradient · CSS gradient</div>
</article>
<article style="display:grid;gap:.35rem;align-content:start">
<mv-blur-up placeholder="data:image/webp;base64,UklGRlAAAABXRUJQVlA4IEQAAAAQAgCdASoQAAwAAsBMJYgCdAEO9KZcXd/AAP6jaiAzWAc7aWxMBso6uj17/L9mJvMdTAvEWsYDrxGfVvigvzPpHAAAAA==" style="border-radius:var(--mv-radius-lg);margin-bottom:.3rem">
<img src="data:image/jpeg;base64,AAAA" width="800" height="600" alt="Tiled courtyard of Riad Zitoun with an orange tree" loading="lazy">
</mv-blur-up>
<div style="display:flex;justify-content:space-between;gap:.5rem;font-size:.9rem"><strong>Riad Zitoun</strong><span>$129 <span style="color:var(--mv-fg-muted)">night</span></span></div>
<div style="color:var(--mv-fg-muted);font-size:.8rem">Marrakesh, Morocco · Nov 20-24</div>
<div style="font:.7rem/1.4 var(--mv-font-mono);color:var(--mv-fg-subtle)">failed load · alt text, no broken icon</div>
</article>
</div>
</div>
<script type="module">
const root = document.getElementById("mv-blur-up-demo");
let run = 0;
root.querySelector("#mv-blur-up-demo-replay").addEventListener("click", () => {
const id = ++run;
root.querySelectorAll("mv-blur-up img").forEach((img, i) => {
const src = (img.dataset.src ??= img.getAttribute("src"));
img.removeAttribute("src");
// A new URL each time, so the browser cannot answer from its cache.
setTimeout(() => { if (id === run) img.src = `${src}#replay-${id}`; }, 900 + i * 450);
});
});
</script>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
placeholder | URL | Tiny preview image shown blurred (a data: URI of ~100 bytes is ideal). Build-time recipe, no runtime tool: Node + sharp "data:image/webp;base64," + (await sharp(file).resize(16).webp({ quality: 40 }).toBuffer()).toString("base64"), or ImageMagick magick photo.jpg -resize 16x -strip -quality 40 webp:- | base64 -w0. | |
color | CSS color | var(--mv-bg-muted) | Solid dominant color, shown under the placeholder or alone. Recipe: magick photo.jpg -resize 1x1! -format "#%[hex:p{0,0}]" info: (or sharp's stats().dominant). |
gradient | CSS gradient | Tiny CSS placeholder with zero extra bytes to fetch, e.g. linear-gradient(#123a60, #3f7fb0) from two sampled colors. Drawn under placeholder when both are set. | |
data-state | loading | loaded | error | Set by the component (for styling). Absent until the component runs: without JS the image simply shows. | |
data-instant | boolean | Set by the component when the image was shown without a fade (cached, loaded before the placeholder was painted, or reduced motion). |
Properties
| Name | Type | Description |
|---|---|---|
state | "loading" | "loaded" | "error" | Current state (read-only). |
image | HTMLImageElement | null | The wrapped <img> (read-only). |
Events
| Name | Description |
|---|---|
mv-load | The real image replaced the placeholder; detail = { src, instant } (instant = no fade was played). |
mv-error | The image failed and the fallback box is shown; detail = { src }. |
Content structure
| Name | Description |
|---|---|
(default) | One <img> with width and height attributes (they reserve the ratio), or a <picture> around it. It may be added, replaced, or have its src/srcset changed at any time. |
CSS classes
| Name | Description |
|---|---|
mv-blur-up-fallback | Generated in the error state only: icon and alt text over the dimmed placeholder (aria-hidden). |
mv-blur-up-alt / mv-blur-up-icon | Parts of the fallback. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-blur-up-blur | 20px | Blur radius of the placeholder. |
--mv-blur-up-duration | var(--mv-duration-slower) | Cross-fade duration. |
--mv-blur-up-fit | cover | object-fit of the image when the box has its own height or aspect-ratio. |
--mv-blur-up-placeholder | none | Placeholder image(s) from CSS, e.g. url(…) set server-side in a style attribute so it shows even before or without JS (the attributes win). |
--mv-blur-up-color | var(--mv-bg-muted) | Placeholder color from CSS (the color attribute wins). |
Accessibility
The native <img> stays the only thing assistive tech meets: its alt, srcset, sizes and loading are never touched, and the placeholder is a CSS pseudo-element with no accessible name. Nothing is announced (image loading is not news). On failure the <img> is kept, only made transparent, so screen readers still read its alt; the visible fallback repeats that alt next to a small icon and is aria-hidden, so it is never read twice, and an empty alt (decorative image) shows only the icon. The box keeps its ratio from the width/height attributes in every state, so nothing shifts on load or on error. Not focusable by itself: a link or button around it keeps its own focus ring. Reduced motion (system or data-motion="reduce"): no fade, the image replaces the placeholder at once. Cached images, and images that load before the placeholder was ever painted, never fade either. Forced colors: the placeholder keeps its colors like an image would, the fallback uses Canvas and CanvasText. Print: the real image at full opacity. Without JavaScript the <img> behaves exactly as native (with --mv-blur-up-placeholder, the placeholder still shows behind it).