24 CSS Animated Cards05 / 24
Blur-to-Sharp Lazy Image Card
The blur-up every image-heavy card grid should ship: a tiny gradient or LQIP renders instantly, the real photo decodes off-thread, then unblurs and settles into place. Zero layout shift because the aspect-ratio is reserved up front, and the whole reveal is four CSS declarations plus img.decode(). Press Reload images to watch it again with a simulated slow connection.
Published
The code
<section class="ac-05" aria-label="Blur to sharp lazy image card demo">
<div class="ac-05__stage">
<header class="ac-05__head">
<p class="ac-05__eyebrow">Progressive imagery</p>
<h2 class="ac-05__title">Blur-up cards with zero layout shift</h2>
<button class="ac-05__btn" type="button" data-reload>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
Reload images
</button>
</header>
<ul class="ac-05__grid" role="list">
<li class="ac-05__card" style="--ac-05-a:oklch(0.55 0.13 235);--ac-05-b:oklch(0.30 0.08 265)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=800&auto=format&fit=crop" alt="Mist between tall conifers at dawn" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Cascade Ridge</h3><p class="ac-05__meta">Oregon · 4.8 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.62 0.14 55);--ac-05-b:oklch(0.34 0.09 30)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?q=80&w=800&auto=format&fit=crop" alt="Sunrise light across a mountain range" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Golden Hour Pass</h3><p class="ac-05__meta">Dolomites · 6.1 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.58 0.10 165);--ac-05-b:oklch(0.30 0.06 195)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=800&auto=format&fit=crop" alt="Sunbeams through a green forest canopy" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Understory</h3><p class="ac-05__meta">Vancouver Island · 5.2 MB source</p></div>
</li>
</ul>
<p class="ac-05__status" role="status" aria-live="polite" data-status>Ready</p>
<p class="ac-05__chip" aria-hidden="true">aspect-ratio reservation · gradient LQIP · img.decode() · blur(18px) → 0 with 6% overscale</p>
</div>
</section><section class="ac-05" aria-label="Blur to sharp lazy image card demo">
<div class="ac-05__stage">
<header class="ac-05__head">
<p class="ac-05__eyebrow">Progressive imagery</p>
<h2 class="ac-05__title">Blur-up cards with zero layout shift</h2>
<button class="ac-05__btn" type="button" data-reload>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
Reload images
</button>
</header>
<ul class="ac-05__grid" role="list">
<li class="ac-05__card" style="--ac-05-a:oklch(0.55 0.13 235);--ac-05-b:oklch(0.30 0.08 265)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=800&auto=format&fit=crop" alt="Mist between tall conifers at dawn" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Cascade Ridge</h3><p class="ac-05__meta">Oregon · 4.8 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.62 0.14 55);--ac-05-b:oklch(0.34 0.09 30)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?q=80&w=800&auto=format&fit=crop" alt="Sunrise light across a mountain range" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Golden Hour Pass</h3><p class="ac-05__meta">Dolomites · 6.1 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.58 0.10 165);--ac-05-b:oklch(0.30 0.06 195)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=800&auto=format&fit=crop" alt="Sunbeams through a green forest canopy" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Understory</h3><p class="ac-05__meta">Vancouver Island · 5.2 MB source</p></div>
</li>
</ul>
<p class="ac-05__status" role="status" aria-live="polite" data-status>Ready</p>
<p class="ac-05__chip" aria-hidden="true">aspect-ratio reservation · gradient LQIP · img.decode() · blur(18px) → 0 with 6% overscale</p>
</div>
</section>.ac-05,
.ac-05 *,
.ac-05 *::before,
.ac-05 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-05 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-05-bg: oklch(0.17 0.014 255);
--ac-05-card: oklch(0.22 0.016 255);
--ac-05-ink: oklch(0.97 0.004 260);
--ac-05-mut: oklch(0.70 0.02 260);
--ac-05-acc: oklch(0.83 0.14 195);
--ac-05-line: oklch(1 0 0/.11);
background: var(--ac-05-bg);
color: var(--ac-05-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-05__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(16px,2.6vw,28px);
padding: clamp(20px,5vw,58px);
background: radial-gradient(100% 70% at 50% 0%,color-mix(in oklch,var(--ac-05-acc) 10%,transparent),transparent 62%);
}
.ac-05__head {
display: grid;
justify-items: center;
gap: 11px;
text-align: center;
}
.ac-05__eyebrow {
font-size: 11.5px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ac-05-acc);
}
.ac-05__title {
font-size: clamp(23px,3.4vw,38px);
font-weight: 650;
line-height: 1.12;
letter-spacing: -.03em;
text-wrap: balance;
}
.ac-05__btn {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 0 20px;
margin-top: 4px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
background: oklch(1 0 0/.05);
color: var(--ac-05-ink);
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-05__btn:hover {
background: oklch(1 0 0/.1);
transform: translateY(-1px);
}
.ac-05__btn:focus-visible {
outline: 2px solid var(--ac-05-acc);
outline-offset: 3px;
}
.ac-05__grid {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(240px,1fr));
gap: clamp(13px,1.8vw,18px);
width: min(100%,940px);
list-style: none;
}
.ac-05__card {
display: grid;
gap: 0;
border: 1px solid var(--ac-05-line);
border-radius: 20px;
overflow: hidden;
background: var(--ac-05-card);
}
.ac-05__media {
position: relative;
display: block;
aspect-ratio: 3/2;
overflow: clip;
background: linear-gradient(135deg,var(--ac-05-a),var(--ac-05-b));
}
.ac-05__media::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(115deg,transparent 30%,oklch(1 0 0/.10) 50%,transparent 70%);
background-size: 220% 100%;
animation: ac-05-sweep 1.5s linear infinite;
opacity: 1;
transition: opacity .4s;
}
.ac-05__media:has(.ac-05__img[data-loaded])::after {
opacity: 0;
animation: none;
}
.ac-05__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
filter: blur(18px) saturate(1.4);
scale: 1.06;
transition: opacity .55s ease-out,filter .7s cubic-bezier(.16,1,.3,1),scale .7s cubic-bezier(.16,1,.3,1);
}
.ac-05__img[data-loaded] {
opacity: 1;
filter: blur(0) saturate(1);
scale: 1;
}
.ac-05__body {
display: grid;
gap: 4px;
padding: 15px 16px 18px;
}
.ac-05__name {
font-size: 15.5px;
font-weight: 640;
letter-spacing: -.02em;
}
.ac-05__meta {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__status {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-05-mut);
padding: 8px 15px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,680px);
}
@keyframes ac-05-sweep {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ac-05__img {
transition-duration: .01ms;
}
.ac-05__media::after {
animation: none;
}
}.ac-05,
.ac-05 *,
.ac-05 *::before,
.ac-05 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-05 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-05-bg: oklch(0.17 0.014 255);
--ac-05-card: oklch(0.22 0.016 255);
--ac-05-ink: oklch(0.97 0.004 260);
--ac-05-mut: oklch(0.70 0.02 260);
--ac-05-acc: oklch(0.83 0.14 195);
--ac-05-line: oklch(1 0 0/.11);
background: var(--ac-05-bg);
color: var(--ac-05-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-05__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(16px,2.6vw,28px);
padding: clamp(20px,5vw,58px);
background: radial-gradient(100% 70% at 50% 0%,color-mix(in oklch,var(--ac-05-acc) 10%,transparent),transparent 62%);
}
.ac-05__head {
display: grid;
justify-items: center;
gap: 11px;
text-align: center;
}
.ac-05__eyebrow {
font-size: 11.5px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ac-05-acc);
}
.ac-05__title {
font-size: clamp(23px,3.4vw,38px);
font-weight: 650;
line-height: 1.12;
letter-spacing: -.03em;
text-wrap: balance;
}
.ac-05__btn {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 0 20px;
margin-top: 4px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
background: oklch(1 0 0/.05);
color: var(--ac-05-ink);
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-05__btn:hover {
background: oklch(1 0 0/.1);
transform: translateY(-1px);
}
.ac-05__btn:focus-visible {
outline: 2px solid var(--ac-05-acc);
outline-offset: 3px;
}
.ac-05__grid {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(240px,1fr));
gap: clamp(13px,1.8vw,18px);
width: min(100%,940px);
list-style: none;
}
.ac-05__card {
display: grid;
gap: 0;
border: 1px solid var(--ac-05-line);
border-radius: 20px;
overflow: hidden;
background: var(--ac-05-card);
}
.ac-05__media {
position: relative;
display: block;
aspect-ratio: 3/2;
overflow: clip;
background: linear-gradient(135deg,var(--ac-05-a),var(--ac-05-b));
}
.ac-05__media::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(115deg,transparent 30%,oklch(1 0 0/.10) 50%,transparent 70%);
background-size: 220% 100%;
animation: ac-05-sweep 1.5s linear infinite;
opacity: 1;
transition: opacity .4s;
}
.ac-05__media:has(.ac-05__img[data-loaded])::after {
opacity: 0;
animation: none;
}
.ac-05__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
filter: blur(18px) saturate(1.4);
scale: 1.06;
transition: opacity .55s ease-out,filter .7s cubic-bezier(.16,1,.3,1),scale .7s cubic-bezier(.16,1,.3,1);
}
.ac-05__img[data-loaded] {
opacity: 1;
filter: blur(0) saturate(1);
scale: 1;
}
.ac-05__body {
display: grid;
gap: 4px;
padding: 15px 16px 18px;
}
.ac-05__name {
font-size: 15.5px;
font-weight: 640;
letter-spacing: -.02em;
}
.ac-05__meta {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__status {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-05-mut);
padding: 8px 15px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,680px);
}
@keyframes ac-05-sweep {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ac-05__img {
transition-duration: .01ms;
}
.ac-05__media::after {
animation: none;
}
}(() => {
document.querySelectorAll('.ac-05').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const imgs = [...root.querySelectorAll('[data-img]')];
const status = root.querySelector('[data-status]');
const load = (img, delay) => new Promise((done) => setTimeout(() => {
img.removeAttribute('data-loaded');
img.src = img.dataset.src + '&cb=' + Date.now();
const reveal = () => { img.dataset.loaded = ''; done(); };
// decode() resolves when the frame is paint-ready — load fires before rasterisation finishes
if (img.decode) img.decode().then(reveal, reveal); else img.addEventListener('load', reveal, { once: true });
}, delay));
const run = () => {
status.textContent = 'Decoding ' + imgs.length + ' images…';
Promise.all(imgs.map((img, i) => load(img, 180 + i * 260)))
.then(() => { status.textContent = 'All images decoded and revealed.'; });
};
root.querySelector('[data-reload]').addEventListener('click', run);
run();
});
})();(() => {
document.querySelectorAll('.ac-05').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const imgs = [...root.querySelectorAll('[data-img]')];
const status = root.querySelector('[data-status]');
const load = (img, delay) => new Promise((done) => setTimeout(() => {
img.removeAttribute('data-loaded');
img.src = img.dataset.src + '&cb=' + Date.now();
const reveal = () => { img.dataset.loaded = ''; done(); };
// decode() resolves when the frame is paint-ready — load fires before rasterisation finishes
if (img.decode) img.decode().then(reveal, reveal); else img.addEventListener('load', reveal, { once: true });
}, delay));
const run = () => {
status.textContent = 'Decoding ' + imgs.length + ' images…';
Promise.all(imgs.map((img, i) => load(img, 180 + i * 260)))
.then(() => { status.textContent = 'All images decoded and revealed.'; });
};
root.querySelector('[data-reload]').addEventListener('click', run);
run();
});
})();Here's a working CSS Animated Card from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Blur-to-Sharp Lazy Image Card
Source: https://codefronts.com/components/css-animated-cards/blur-to-sharp-lazy-image-card/
The blur-up every image-heavy card grid should ship: a tiny gradient or LQIP renders instantly, the real photo decodes off-thread, then unblurs and settles into place. Zero layout shift because the aspect-ratio is reserved up front, and the whole reveal is four CSS declarations plus img.decode(). Press Reload images to watch it again with a simulated slow connection.
## HTML
```html
<section class="ac-05" aria-label="Blur to sharp lazy image card demo">
<div class="ac-05__stage">
<header class="ac-05__head">
<p class="ac-05__eyebrow">Progressive imagery</p>
<h2 class="ac-05__title">Blur-up cards with zero layout shift</h2>
<button class="ac-05__btn" type="button" data-reload>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
Reload images
</button>
</header>
<ul class="ac-05__grid" role="list">
<li class="ac-05__card" style="--ac-05-a:oklch(0.55 0.13 235);--ac-05-b:oklch(0.30 0.08 265)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=800&auto=format&fit=crop" alt="Mist between tall conifers at dawn" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Cascade Ridge</h3><p class="ac-05__meta">Oregon · 4.8 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.62 0.14 55);--ac-05-b:oklch(0.34 0.09 30)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?q=80&w=800&auto=format&fit=crop" alt="Sunrise light across a mountain range" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Golden Hour Pass</h3><p class="ac-05__meta">Dolomites · 6.1 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.58 0.10 165);--ac-05-b:oklch(0.30 0.06 195)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=800&auto=format&fit=crop" alt="Sunbeams through a green forest canopy" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Understory</h3><p class="ac-05__meta">Vancouver Island · 5.2 MB source</p></div>
</li>
</ul>
<p class="ac-05__status" role="status" aria-live="polite" data-status>Ready</p>
<p class="ac-05__chip" aria-hidden="true">aspect-ratio reservation · gradient LQIP · img.decode() · blur(18px) → 0 with 6% overscale</p>
</div>
</section>
```
## CSS
```css
.ac-05,
.ac-05 *,
.ac-05 *::before,
.ac-05 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-05 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-05-bg: oklch(0.17 0.014 255);
--ac-05-card: oklch(0.22 0.016 255);
--ac-05-ink: oklch(0.97 0.004 260);
--ac-05-mut: oklch(0.70 0.02 260);
--ac-05-acc: oklch(0.83 0.14 195);
--ac-05-line: oklch(1 0 0/.11);
background: var(--ac-05-bg);
color: var(--ac-05-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-05__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(16px,2.6vw,28px);
padding: clamp(20px,5vw,58px);
background: radial-gradient(100% 70% at 50% 0%,color-mix(in oklch,var(--ac-05-acc) 10%,transparent),transparent 62%);
}
.ac-05__head {
display: grid;
justify-items: center;
gap: 11px;
text-align: center;
}
.ac-05__eyebrow {
font-size: 11.5px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ac-05-acc);
}
.ac-05__title {
font-size: clamp(23px,3.4vw,38px);
font-weight: 650;
line-height: 1.12;
letter-spacing: -.03em;
text-wrap: balance;
}
.ac-05__btn {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 0 20px;
margin-top: 4px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
background: oklch(1 0 0/.05);
color: var(--ac-05-ink);
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-05__btn:hover {
background: oklch(1 0 0/.1);
transform: translateY(-1px);
}
.ac-05__btn:focus-visible {
outline: 2px solid var(--ac-05-acc);
outline-offset: 3px;
}
.ac-05__grid {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(240px,1fr));
gap: clamp(13px,1.8vw,18px);
width: min(100%,940px);
list-style: none;
}
.ac-05__card {
display: grid;
gap: 0;
border: 1px solid var(--ac-05-line);
border-radius: 20px;
overflow: hidden;
background: var(--ac-05-card);
}
.ac-05__media {
position: relative;
display: block;
aspect-ratio: 3/2;
overflow: clip;
background: linear-gradient(135deg,var(--ac-05-a),var(--ac-05-b));
}
.ac-05__media::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(115deg,transparent 30%,oklch(1 0 0/.10) 50%,transparent 70%);
background-size: 220% 100%;
animation: ac-05-sweep 1.5s linear infinite;
opacity: 1;
transition: opacity .4s;
}
.ac-05__media:has(.ac-05__img[data-loaded])::after {
opacity: 0;
animation: none;
}
.ac-05__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
filter: blur(18px) saturate(1.4);
scale: 1.06;
transition: opacity .55s ease-out,filter .7s cubic-bezier(.16,1,.3,1),scale .7s cubic-bezier(.16,1,.3,1);
}
.ac-05__img[data-loaded] {
opacity: 1;
filter: blur(0) saturate(1);
scale: 1;
}
.ac-05__body {
display: grid;
gap: 4px;
padding: 15px 16px 18px;
}
.ac-05__name {
font-size: 15.5px;
font-weight: 640;
letter-spacing: -.02em;
}
.ac-05__meta {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__status {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-05-mut);
padding: 8px 15px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,680px);
}
@keyframes ac-05-sweep {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ac-05__img {
transition-duration: .01ms;
}
.ac-05__media::after {
animation: none;
}
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('.ac-05').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const imgs = [...root.querySelectorAll('[data-img]')];
const status = root.querySelector('[data-status]');
const load = (img, delay) => new Promise((done) => setTimeout(() => {
img.removeAttribute('data-loaded');
img.src = img.dataset.src + '&cb=' + Date.now();
const reveal = () => { img.dataset.loaded = ''; done(); };
// decode() resolves when the frame is paint-ready — load fires before rasterisation finishes
if (img.decode) img.decode().then(reveal, reveal); else img.addEventListener('load', reveal, { once: true });
}, delay));
const run = () => {
status.textContent = 'Decoding ' + imgs.length + ' images…';
Promise.all(imgs.map((img, i) => load(img, 180 + i * 260)))
.then(() => { status.textContent = 'All images decoded and revealed.'; });
};
root.querySelector('[data-reload]').addEventListener('click', run);
run();
});
})();
```Here's a working CSS Animated Card from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Blur-to-Sharp Lazy Image Card
Source: https://codefronts.com/components/css-animated-cards/blur-to-sharp-lazy-image-card/
The blur-up every image-heavy card grid should ship: a tiny gradient or LQIP renders instantly, the real photo decodes off-thread, then unblurs and settles into place. Zero layout shift because the aspect-ratio is reserved up front, and the whole reveal is four CSS declarations plus img.decode(). Press Reload images to watch it again with a simulated slow connection.
## HTML
```html
<section class="ac-05" aria-label="Blur to sharp lazy image card demo">
<div class="ac-05__stage">
<header class="ac-05__head">
<p class="ac-05__eyebrow">Progressive imagery</p>
<h2 class="ac-05__title">Blur-up cards with zero layout shift</h2>
<button class="ac-05__btn" type="button" data-reload>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
Reload images
</button>
</header>
<ul class="ac-05__grid" role="list">
<li class="ac-05__card" style="--ac-05-a:oklch(0.55 0.13 235);--ac-05-b:oklch(0.30 0.08 265)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=800&auto=format&fit=crop" alt="Mist between tall conifers at dawn" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Cascade Ridge</h3><p class="ac-05__meta">Oregon · 4.8 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.62 0.14 55);--ac-05-b:oklch(0.34 0.09 30)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?q=80&w=800&auto=format&fit=crop" alt="Sunrise light across a mountain range" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Golden Hour Pass</h3><p class="ac-05__meta">Dolomites · 6.1 MB source</p></div>
</li>
<li class="ac-05__card" style="--ac-05-a:oklch(0.58 0.10 165);--ac-05-b:oklch(0.30 0.06 195)">
<span class="ac-05__media"><img class="ac-05__img" data-img data-src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=800&auto=format&fit=crop" alt="Sunbeams through a green forest canopy" width="800" height="533" loading="lazy" decoding="async"></span>
<div class="ac-05__body"><h3 class="ac-05__name">Understory</h3><p class="ac-05__meta">Vancouver Island · 5.2 MB source</p></div>
</li>
</ul>
<p class="ac-05__status" role="status" aria-live="polite" data-status>Ready</p>
<p class="ac-05__chip" aria-hidden="true">aspect-ratio reservation · gradient LQIP · img.decode() · blur(18px) → 0 with 6% overscale</p>
</div>
</section>
```
## CSS
```css
.ac-05,
.ac-05 *,
.ac-05 *::before,
.ac-05 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-05 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-05-bg: oklch(0.17 0.014 255);
--ac-05-card: oklch(0.22 0.016 255);
--ac-05-ink: oklch(0.97 0.004 260);
--ac-05-mut: oklch(0.70 0.02 260);
--ac-05-acc: oklch(0.83 0.14 195);
--ac-05-line: oklch(1 0 0/.11);
background: var(--ac-05-bg);
color: var(--ac-05-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-05__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: clamp(16px,2.6vw,28px);
padding: clamp(20px,5vw,58px);
background: radial-gradient(100% 70% at 50% 0%,color-mix(in oklch,var(--ac-05-acc) 10%,transparent),transparent 62%);
}
.ac-05__head {
display: grid;
justify-items: center;
gap: 11px;
text-align: center;
}
.ac-05__eyebrow {
font-size: 11.5px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ac-05-acc);
}
.ac-05__title {
font-size: clamp(23px,3.4vw,38px);
font-weight: 650;
line-height: 1.12;
letter-spacing: -.03em;
text-wrap: balance;
}
.ac-05__btn {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
padding: 0 20px;
margin-top: 4px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
background: oklch(1 0 0/.05);
color: var(--ac-05-ink);
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-05__btn:hover {
background: oklch(1 0 0/.1);
transform: translateY(-1px);
}
.ac-05__btn:focus-visible {
outline: 2px solid var(--ac-05-acc);
outline-offset: 3px;
}
.ac-05__grid {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(240px,1fr));
gap: clamp(13px,1.8vw,18px);
width: min(100%,940px);
list-style: none;
}
.ac-05__card {
display: grid;
gap: 0;
border: 1px solid var(--ac-05-line);
border-radius: 20px;
overflow: hidden;
background: var(--ac-05-card);
}
.ac-05__media {
position: relative;
display: block;
aspect-ratio: 3/2;
overflow: clip;
background: linear-gradient(135deg,var(--ac-05-a),var(--ac-05-b));
}
.ac-05__media::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(115deg,transparent 30%,oklch(1 0 0/.10) 50%,transparent 70%);
background-size: 220% 100%;
animation: ac-05-sweep 1.5s linear infinite;
opacity: 1;
transition: opacity .4s;
}
.ac-05__media:has(.ac-05__img[data-loaded])::after {
opacity: 0;
animation: none;
}
.ac-05__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
filter: blur(18px) saturate(1.4);
scale: 1.06;
transition: opacity .55s ease-out,filter .7s cubic-bezier(.16,1,.3,1),scale .7s cubic-bezier(.16,1,.3,1);
}
.ac-05__img[data-loaded] {
opacity: 1;
filter: blur(0) saturate(1);
scale: 1;
}
.ac-05__body {
display: grid;
gap: 4px;
padding: 15px 16px 18px;
}
.ac-05__name {
font-size: 15.5px;
font-weight: 640;
letter-spacing: -.02em;
}
.ac-05__meta {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__status {
font-size: 12.5px;
color: var(--ac-05-mut);
font-variant-numeric: tabular-nums;
}
.ac-05__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-05-mut);
padding: 8px 15px;
border: 1px solid var(--ac-05-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,680px);
}
@keyframes ac-05-sweep {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ac-05__img {
transition-duration: .01ms;
}
.ac-05__media::after {
animation: none;
}
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('.ac-05').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const imgs = [...root.querySelectorAll('[data-img]')];
const status = root.querySelector('[data-status]');
const load = (img, delay) => new Promise((done) => setTimeout(() => {
img.removeAttribute('data-loaded');
img.src = img.dataset.src + '&cb=' + Date.now();
const reveal = () => { img.dataset.loaded = ''; done(); };
// decode() resolves when the frame is paint-ready — load fires before rasterisation finishes
if (img.decode) img.decode().then(reveal, reveal); else img.addEventListener('load', reveal, { once: true });
}, delay));
const run = () => {
status.textContent = 'Decoding ' + imgs.length + ' images…';
Promise.all(imgs.map((img, i) => load(img, 180 + i * 260)))
.then(() => { status.textContent = 'All images decoded and revealed.'; });
};
root.querySelector('[data-reload]').addEventListener('click', run);
run();
});
})();
```How this works
Three problems, three fixes. Layout shift: reserve the box before the bytes arrive — width and height attributes plus aspect-ratio mean the card never jumps:
.media { aspect-ratio: 3/2; background: linear-gradient(...) } /* LQIP stand-in */
.img { opacity: 0; filter: blur(18px); scale: 1.06;
transition: opacity .6s, filter .7s, scale .7s }
.img[data-loaded] { opacity: 1; filter: blur(0); scale: 1 }The decode stall: setting src and listening for load still lets a large JPEG block the main thread while it rasterises. await img.decode() resolves once the frame is ready to paint, so the unblur begins on a frame that can actually render:
img.src = img.dataset.src;
img.decode().catch(() => {}).then(() => { img.dataset.loaded = ''; });The blur halo: a blurred element bleeds transparent edges. Scaling it 6% larger under overflow: clip pushes that halo outside the visible box, and scaling back to 1 as it sharpens doubles as the reveal motion.
The placeholder here is a two-stop gradient sampled from the photo's dominant colours — the same idea as a base64 LQIP or a BlurHash, without the extra request. Native loading="lazy" plus decoding="async" handles everything below the fold with no JavaScript at all.
Make it yours
- Real LQIP: swap the gradient for a 20 px base64 JPEG as
background-image; identical CSS, more faithful colour. - Cross-fade instead of unblur: keep the placeholder as a sibling and fade it out — better when the LQIP is a real thumbnail.
- Skeleton shimmer while waiting: add the sweep from demo 11 to the media box and stop it on
[data-loaded]. - Priority hints:
fetchpriority="high"on the first card andloading="eager"above the fold; keep lazy for the rest.
Gotchas — read before shipping
- Never lazy-load your LCP image.
loading="lazy"on a hero delays the largest paint and costs you the Core Web Vitals score you were trying to protect. img.decode()rejects if the src changes mid-flight — always.catch()it, or a swapped source throws an unhandled rejection.- Animating
filter: blur()is expensive on large surfaces. Keep it short (under ~700 ms), on a single layer, and never on a dozen images simultaneously. - Without width/height attributes the aspect-ratio box collapses before CSS loads, and you get exactly the CLS you were avoiding.
- The blur halo problem is real: without
overflow: clipon the wrapper plus a slight upscale, you get a soft translucent rim on every card.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 77+.
loading=lazy, decoding=async, HTMLImageElement.decode() and aspect-ratio are all Baseline Widely Available. Where decode() is missing the code falls back to the load event, so the reveal is identical.