22 CSS Skeleton Loaders05 / 22

Light JSMIT licensed

SVG Placeholder Blur-Up Image Skeleton

The Medium / WordPress blur-up pattern, hand-rolled: a two-shape inline SVG blurred to blur(20px) sits inside an aspect-ratio box and cross-fades out when the real photograph decodes. Press Simulate slow load to replay the 1.2s reveal as many times as you need to get the easing right.

Published

Live Demo
Try it

The code

<div class="sk-05">
  <div class="sk-05__stage">
    <section class="sk-05__wrap" aria-labelledby="sk-05-h">
      <header class="sk-05__head">
        <p class="sk-05__eyebrow">LQIP · blur-up</p>
        <h2 class="sk-05__h" id="sk-05-h">Ship a blurred guess in the first frame</h2>
        <p class="sk-05__sub">An inline SVG placeholder costs a few hundred bytes and paints instantly. The photograph cross-fades over it when <code>decode()</code> resolves — and the box never changes size.</p>
        <div class="sk-05__controls">
          <button class="sk-05__btn" id="sk-05-go" type="button">
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M4 12a8 8 0 1 1 2.6 5.9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M4 6.5V12h5.4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
            Simulate slow load
          </button>
          <p class="sk-05__status" id="sk-05-msg" role="status" aria-live="polite">Placeholder shown · waiting for decode</p>
        </div>
      </header>

      <figure class="sk-05__figure">
        <div class="sk-05__frame" id="sk-05-frame">
          <svg class="sk-05__lqip" viewBox="0 0 32 20" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
            <rect width="32" height="20" fill="#1d3557"/>
            <ellipse cx="9" cy="7" rx="11" ry="7" fill="#e07a5f"/>
            <ellipse cx="25" cy="15" rx="12" ry="8" fill="#3d5a80"/>
            <ellipse cx="20" cy="4" rx="7" ry="4" fill="#f2cc8f"/>
          </svg>
          <img class="sk-05__img" id="sk-05-img" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&amp;fit=crop&amp;w=1280&amp;h=800&amp;q=72" width="1280" height="800" alt="Fog moving through a dark pine forest at dawn" decoding="async" fetchpriority="low">
        </div>
        <figcaption class="sk-05__cap">
          <span class="sk-05__captitle">Foggy pines, 1280×800</span>
          <span class="sk-05__capmeta">aspect-ratio: 16 / 10 · CLS 0.00 · placeholder 412 B</span>
        </figcaption>
      </figure>

      <aside class="sk-05__spec" aria-label="Recipe">
        <p class="sk-05__srow"><span class="sk-05__skey">box</span><span class="sk-05__sval">aspect-ratio: 16/10 + overflow: hidden — reserves space before any byte lands</span></p>
        <p class="sk-05__srow"><span class="sk-05__skey">lqip</span><span class="sk-05__sval">filter: blur(20px); scale: 1.08 — blur then overscale to hide edge bleed</span></p>
        <p class="sk-05__srow"><span class="sk-05__skey">reveal</span><span class="sk-05__sval">await img.decode() → opacity 0→1 over 1.2s cubic-bezier(.2,.7,.3,1)</span></p>
      </aside>
    </section>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');

.sk-05 {
  --bg: #06080a;
  --bg2: #0d1418;
  --surface: rgba(255,255,255,.03);
  --line: rgba(255,255,255,.1);
  --ink: #f2f7f7;
  --muted: rgba(242,247,247,.62);
  --faint: rgba(242,247,247,.42);
  --accent: #5eead4;
  --sk: rgba(242,247,247,.09);
  --mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  box-sizing: border-box;
  color: var(--ink);
  isolation: isolate;
  font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  background: radial-gradient(60% 45% at 10% -5%, rgba(94,234,212,.12) 0%, transparent 60%), linear-gradient(178deg,var(--bg2) 0%,var(--bg) 62%);
}

.sk-05 *,
.sk-05 *::before,
.sk-05 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0.7 0.1 180)) {
  .sk-05 {
    --ink: oklch(0.97 0.008 190);
    --muted: oklch(0.97 0.008 190 / .62);
    --faint: oklch(0.97 0.008 190 / .42);
    --accent: oklch(0.85 0.12 178);
  }
}

@supports (color: color-mix(in oklab, white 10%, transparent)) {
  .sk-05 {
    --sk: color-mix(in oklab, currentColor 9%, transparent);
  }
}

.sk-05__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.15rem,4vw,3.25rem);
}

.sk-05__wrap {
  inline-size: min(64rem,100%);
  display: grid;
  gap: clamp(1rem,2.2vw,1.6rem);
}

.sk-05__head {
  display: grid;
  gap: .6rem;
  max-inline-size: 44rem;
}

.sk-05__eyebrow {
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent);
}

.sk-05__h {
  margin: 0;
  font-size: clamp(1.6rem,3.6vw,2.5rem);
  font-weight: 600;
  letter-spacing: -.032em;
  line-height: 1.06;
  text-wrap: balance;
}

.sk-05__sub {
  margin: 0;
  font-size: clamp(.94rem,1.3vw,1.02rem);
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sk-05__sub code {
  font-family: var(--mono);
  font-size: .88em;
  color: var(--ink);
}

.sk-05__controls {
  display: flex;
  align-items: center;
  gap: .9rem;
  flex-wrap: wrap;
  margin-block-start: .35rem;
}

.sk-05__btn {
  min-block-size: 2.75rem;
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  padding: .6rem 1.05rem;
  border-radius: 999px;
  cursor: pointer;
  font: inherit;
  font-size: .88rem;
  font-weight: 500;
  color: #04211d;
  background: var(--accent);
  border: 1px solid transparent;
  transition: translate .18s ease, opacity .18s ease;
}

.sk-05__btn svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
}

.sk-05__btn:hover {
  translate: 0 -1px;
}

.sk-05__btn:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.sk-05__btn[disabled] {
  opacity: .5;
  cursor: progress;
  translate: none;
}

.sk-05__status {
  margin: 0;
  font-family: var(--mono);
  font-size: .74rem;
  color: var(--muted);
}

.sk-05__figure {
  margin: 0;
  display: grid;
  gap: .8rem;
}

.sk-05__frame {
  position: relative;
  overflow: hidden;
  aspect-ratio: 16/10;
  border-radius: 16px;
  border: 1px solid var(--line);
  background: var(--sk);
  box-shadow: 0 34px 80px -46px rgba(0,0,0,.95);
}

.sk-05__lqip,
.sk-05__img {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  display: block;
}

.sk-05__lqip {
  filter: blur(20px);
  scale: 1.08;
  transition: opacity 1.2s cubic-bezier(.2,.7,.3,1);
}

.sk-05__img {
  object-fit: cover;
  opacity: 0;
  filter: blur(6px);
  scale: 1.02;
  transition: opacity 1.2s cubic-bezier(.2,.7,.3,1), filter 1.2s cubic-bezier(.2,.7,.3,1), scale 1.2s cubic-bezier(.2,.7,.3,1);
}

.sk-05__frame.is-loaded .sk-05__img {
  opacity: 1;
  filter: blur(0);
  scale: 1;
}

.sk-05__frame.is-loaded .sk-05__lqip {
  opacity: 0;
}

.sk-05__frame::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,.1) 50%, transparent 80%);
  translate: -100% 0;
  animation: sk-05-sheen 1.6s linear infinite;
}

.sk-05__frame.is-loaded::after {
  animation: none;
  opacity: 0;
}

@keyframes sk-05-sheen {
  to {
    translate: 100% 0;
  }
}

.sk-05__cap {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.sk-05__captitle {
  font-size: .9rem;
  color: var(--ink);
}

.sk-05__capmeta {
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--faint);
}

.sk-05__spec {
  padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
  border: 1px solid var(--line);
  border-radius: .9rem;
  background: var(--surface);
  display: grid;
  gap: .45rem;
}

.sk-05__srow {
  margin: 0;
  display: flex;
  gap: .9rem;
  flex-wrap: wrap;
  font-family: var(--mono);
  font-size: .74rem;
  line-height: 1.5;
}

.sk-05__skey {
  min-inline-size: 3.5rem;
  color: var(--accent);
  letter-spacing: .08em;
  text-transform: uppercase;
  font-size: .66rem;
  padding-block-start: .12rem;
}

.sk-05__sval {
  color: var(--muted);
  word-break: break-word;
}

.sk-05[data-theme="light"] {
  --bg: #f6faf9;
  --bg2: #fff;
  --surface: rgba(4,20,18,.028);
  --line: rgba(4,20,18,.11);
  --ink: #04120f;
  --muted: rgba(4,18,15,.62);
  --faint: rgba(4,18,15,.44);
  --accent: #0f766e;
  background: linear-gradient(178deg,#fff 0%,#e9f4f1 100%);
}

@media (prefers-color-scheme: light) {
  .sk-05:not([data-theme="dark"]) {
    --bg: #f6faf9;
    --bg2: #fff;
    --surface: rgba(4,20,18,.028);
    --line: rgba(4,20,18,.11);
    --ink: #04120f;
    --muted: rgba(4,18,15,.62);
    --faint: rgba(4,18,15,.44);
    --accent: #0f766e;
    background: linear-gradient(178deg,#fff 0%,#e9f4f1 100%);
  }

  .sk-05__btn {
    color: #fff;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sk-05__frame::after {
    animation: none;
    background: none;
  }

  .sk-05__lqip,
    .sk-05__img {
    transition-duration: .01ms;
  }

  .sk-05__btn {
    transition: none;
  }
}

@media (forced-colors: active) {
  .sk-05__frame,
    .sk-05__spec,
    .sk-05__btn {
    border-color: CanvasText;
  }

  .sk-05__frame::after {
    animation: none;
    background: none;
  }
}
const frame = document.getElementById('sk-05-frame');
const img = document.getElementById('sk-05-img');
const msg = document.getElementById('sk-05-msg');
const go = document.getElementById('sk-05-go');
const src = img.currentSrc || img.src;

const reveal = async () => {
  try { await img.decode(); } catch { /* older engines: load event already fired */ }
  frame.classList.add('is-loaded');
  msg.textContent = 'Image decoded · cross-faded over 1.2s';
  go.disabled = false;
};

go.addEventListener('click', () => {
  go.disabled = true;
  frame.classList.remove('is-loaded');
  msg.textContent = 'Placeholder shown · fetching full image…';
  img.src = '';
  // cache-bust so the decode is observable on every replay
  setTimeout(() => { img.src = src + '&sim=' + Date.now(); reveal(); }, 700);
});

reveal();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Skeleton Loader 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: SVG Placeholder Blur-Up Image Skeleton
Source: https://codefronts.com/components/css-skeleton-loaders/svg-placeholder-blur-up-image-skeleton/

The Medium / WordPress blur-up pattern, hand-rolled: a two-shape inline SVG blurred to blur(20px) sits inside an aspect-ratio box and cross-fades out when the real photograph decodes. Press Simulate slow load to replay the 1.2s reveal as many times as you need to get the easing right.
## HTML
```html
<div class="sk-05">
  <div class="sk-05__stage">
    <section class="sk-05__wrap" aria-labelledby="sk-05-h">
      <header class="sk-05__head">
        <p class="sk-05__eyebrow">LQIP · blur-up</p>
        <h2 class="sk-05__h" id="sk-05-h">Ship a blurred guess in the first frame</h2>
        <p class="sk-05__sub">An inline SVG placeholder costs a few hundred bytes and paints instantly. The photograph cross-fades over it when <code>decode()</code> resolves — and the box never changes size.</p>
        <div class="sk-05__controls">
          <button class="sk-05__btn" id="sk-05-go" type="button">
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M4 12a8 8 0 1 1 2.6 5.9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M4 6.5V12h5.4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
            Simulate slow load
          </button>
          <p class="sk-05__status" id="sk-05-msg" role="status" aria-live="polite">Placeholder shown · waiting for decode</p>
        </div>
      </header>

      <figure class="sk-05__figure">
        <div class="sk-05__frame" id="sk-05-frame">
          <svg class="sk-05__lqip" viewBox="0 0 32 20" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
            <rect width="32" height="20" fill="#1d3557"/>
            <ellipse cx="9" cy="7" rx="11" ry="7" fill="#e07a5f"/>
            <ellipse cx="25" cy="15" rx="12" ry="8" fill="#3d5a80"/>
            <ellipse cx="20" cy="4" rx="7" ry="4" fill="#f2cc8f"/>
          </svg>
          <img class="sk-05__img" id="sk-05-img" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&amp;fit=crop&amp;w=1280&amp;h=800&amp;q=72" width="1280" height="800" alt="Fog moving through a dark pine forest at dawn" decoding="async" fetchpriority="low">
        </div>
        <figcaption class="sk-05__cap">
          <span class="sk-05__captitle">Foggy pines, 1280×800</span>
          <span class="sk-05__capmeta">aspect-ratio: 16 / 10 · CLS 0.00 · placeholder 412 B</span>
        </figcaption>
      </figure>

      <aside class="sk-05__spec" aria-label="Recipe">
        <p class="sk-05__srow"><span class="sk-05__skey">box</span><span class="sk-05__sval">aspect-ratio: 16/10 + overflow: hidden — reserves space before any byte lands</span></p>
        <p class="sk-05__srow"><span class="sk-05__skey">lqip</span><span class="sk-05__sval">filter: blur(20px); scale: 1.08 — blur then overscale to hide edge bleed</span></p>
        <p class="sk-05__srow"><span class="sk-05__skey">reveal</span><span class="sk-05__sval">await img.decode() → opacity 0→1 over 1.2s cubic-bezier(.2,.7,.3,1)</span></p>
      </aside>
    </section>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');

.sk-05 {
  --bg: #06080a;
  --bg2: #0d1418;
  --surface: rgba(255,255,255,.03);
  --line: rgba(255,255,255,.1);
  --ink: #f2f7f7;
  --muted: rgba(242,247,247,.62);
  --faint: rgba(242,247,247,.42);
  --accent: #5eead4;
  --sk: rgba(242,247,247,.09);
  --mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  box-sizing: border-box;
  color: var(--ink);
  isolation: isolate;
  font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  background: radial-gradient(60% 45% at 10% -5%, rgba(94,234,212,.12) 0%, transparent 60%), linear-gradient(178deg,var(--bg2) 0%,var(--bg) 62%);
}

.sk-05 *,
.sk-05 *::before,
.sk-05 *::after {
  box-sizing: border-box;
}

@supports (color: oklch(0.7 0.1 180)) {
  .sk-05 {
    --ink: oklch(0.97 0.008 190);
    --muted: oklch(0.97 0.008 190 / .62);
    --faint: oklch(0.97 0.008 190 / .42);
    --accent: oklch(0.85 0.12 178);
  }
}

@supports (color: color-mix(in oklab, white 10%, transparent)) {
  .sk-05 {
    --sk: color-mix(in oklab, currentColor 9%, transparent);
  }
}

.sk-05__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.15rem,4vw,3.25rem);
}

.sk-05__wrap {
  inline-size: min(64rem,100%);
  display: grid;
  gap: clamp(1rem,2.2vw,1.6rem);
}

.sk-05__head {
  display: grid;
  gap: .6rem;
  max-inline-size: 44rem;
}

.sk-05__eyebrow {
  margin: 0;
  font-family: var(--mono);
  font-size: .72rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent);
}

.sk-05__h {
  margin: 0;
  font-size: clamp(1.6rem,3.6vw,2.5rem);
  font-weight: 600;
  letter-spacing: -.032em;
  line-height: 1.06;
  text-wrap: balance;
}

.sk-05__sub {
  margin: 0;
  font-size: clamp(.94rem,1.3vw,1.02rem);
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.sk-05__sub code {
  font-family: var(--mono);
  font-size: .88em;
  color: var(--ink);
}

.sk-05__controls {
  display: flex;
  align-items: center;
  gap: .9rem;
  flex-wrap: wrap;
  margin-block-start: .35rem;
}

.sk-05__btn {
  min-block-size: 2.75rem;
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  padding: .6rem 1.05rem;
  border-radius: 999px;
  cursor: pointer;
  font: inherit;
  font-size: .88rem;
  font-weight: 500;
  color: #04211d;
  background: var(--accent);
  border: 1px solid transparent;
  transition: translate .18s ease, opacity .18s ease;
}

.sk-05__btn svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
}

.sk-05__btn:hover {
  translate: 0 -1px;
}

.sk-05__btn:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.sk-05__btn[disabled] {
  opacity: .5;
  cursor: progress;
  translate: none;
}

.sk-05__status {
  margin: 0;
  font-family: var(--mono);
  font-size: .74rem;
  color: var(--muted);
}

.sk-05__figure {
  margin: 0;
  display: grid;
  gap: .8rem;
}

.sk-05__frame {
  position: relative;
  overflow: hidden;
  aspect-ratio: 16/10;
  border-radius: 16px;
  border: 1px solid var(--line);
  background: var(--sk);
  box-shadow: 0 34px 80px -46px rgba(0,0,0,.95);
}

.sk-05__lqip,
.sk-05__img {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  display: block;
}

.sk-05__lqip {
  filter: blur(20px);
  scale: 1.08;
  transition: opacity 1.2s cubic-bezier(.2,.7,.3,1);
}

.sk-05__img {
  object-fit: cover;
  opacity: 0;
  filter: blur(6px);
  scale: 1.02;
  transition: opacity 1.2s cubic-bezier(.2,.7,.3,1), filter 1.2s cubic-bezier(.2,.7,.3,1), scale 1.2s cubic-bezier(.2,.7,.3,1);
}

.sk-05__frame.is-loaded .sk-05__img {
  opacity: 1;
  filter: blur(0);
  scale: 1;
}

.sk-05__frame.is-loaded .sk-05__lqip {
  opacity: 0;
}

.sk-05__frame::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,.1) 50%, transparent 80%);
  translate: -100% 0;
  animation: sk-05-sheen 1.6s linear infinite;
}

.sk-05__frame.is-loaded::after {
  animation: none;
  opacity: 0;
}

@keyframes sk-05-sheen {
  to {
    translate: 100% 0;
  }
}

.sk-05__cap {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.sk-05__captitle {
  font-size: .9rem;
  color: var(--ink);
}

.sk-05__capmeta {
  font-family: var(--mono);
  font-size: .72rem;
  color: var(--faint);
}

.sk-05__spec {
  padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
  border: 1px solid var(--line);
  border-radius: .9rem;
  background: var(--surface);
  display: grid;
  gap: .45rem;
}

.sk-05__srow {
  margin: 0;
  display: flex;
  gap: .9rem;
  flex-wrap: wrap;
  font-family: var(--mono);
  font-size: .74rem;
  line-height: 1.5;
}

.sk-05__skey {
  min-inline-size: 3.5rem;
  color: var(--accent);
  letter-spacing: .08em;
  text-transform: uppercase;
  font-size: .66rem;
  padding-block-start: .12rem;
}

.sk-05__sval {
  color: var(--muted);
  word-break: break-word;
}

.sk-05[data-theme="light"] {
  --bg: #f6faf9;
  --bg2: #fff;
  --surface: rgba(4,20,18,.028);
  --line: rgba(4,20,18,.11);
  --ink: #04120f;
  --muted: rgba(4,18,15,.62);
  --faint: rgba(4,18,15,.44);
  --accent: #0f766e;
  background: linear-gradient(178deg,#fff 0%,#e9f4f1 100%);
}

@media (prefers-color-scheme: light) {
  .sk-05:not([data-theme="dark"]) {
    --bg: #f6faf9;
    --bg2: #fff;
    --surface: rgba(4,20,18,.028);
    --line: rgba(4,20,18,.11);
    --ink: #04120f;
    --muted: rgba(4,18,15,.62);
    --faint: rgba(4,18,15,.44);
    --accent: #0f766e;
    background: linear-gradient(178deg,#fff 0%,#e9f4f1 100%);
  }

  .sk-05__btn {
    color: #fff;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sk-05__frame::after {
    animation: none;
    background: none;
  }

  .sk-05__lqip,
    .sk-05__img {
    transition-duration: .01ms;
  }

  .sk-05__btn {
    transition: none;
  }
}

@media (forced-colors: active) {
  .sk-05__frame,
    .sk-05__spec,
    .sk-05__btn {
    border-color: CanvasText;
  }

  .sk-05__frame::after {
    animation: none;
    background: none;
  }
}
```

## JavaScript
```js
const frame = document.getElementById('sk-05-frame');
const img = document.getElementById('sk-05-img');
const msg = document.getElementById('sk-05-msg');
const go = document.getElementById('sk-05-go');
const src = img.currentSrc || img.src;

const reveal = async () => {
  try { await img.decode(); } catch { /* older engines: load event already fired */ }
  frame.classList.add('is-loaded');
  msg.textContent = 'Image decoded · cross-faded over 1.2s';
  go.disabled = false;
};

go.addEventListener('click', () => {
  go.disabled = true;
  frame.classList.remove('is-loaded');
  msg.textContent = 'Placeholder shown · fetching full image…';
  img.src = '';
  // cache-bust so the decode is observable on every replay
  setTimeout(() => { img.src = src + '&sim=' + Date.now(); reveal(); }, 700);
});

reveal();
```

How this works

A blur-up has three layers in one aspect-ratio box: the placeholder (cheap, instant), the real image (expensive, later), and the box itself (which must not resize when they swap). The box is the part people forget — without it the photo's arrival pushes everything below it down:

.sk-05__frame{
  position:relative; overflow:hidden;
  aspect-ratio:16/10;          /* the box is correct before any byte arrives */
  border-radius:14px;
  background:var(--sk);        /* a floor colour, in case both layers fail */
}
.sk-05__frame > *{ position:absolute; inset:0; }

The placeholder is an inline SVG, not a network request. Two blurred ellipses in the image's dominant colours weigh a few hundred bytes and ship in the HTML, so they paint in the first frame. Blur then scale, in that order, and overscale slightly so the softened edge never shows:

.sk-05__lqip{ filter:blur(20px); scale:1.08; }   /* 1.08 hides edge bleed */

Fade on decode(), not on load. load fires when bytes have arrived; the browser may still be decoding, so a fade started there can stutter on its first frame. img.decode() resolves when the bitmap is ready to paint:

const reveal = async () => {
  try { await img.decode(); } catch {}
  frame.classList.add('is-loaded');   // opacity 0→1 on the img, 1→0 on the LQIP
};

In production, generate the LQIP at build time. Sharp, Plaiceholder, next/image's placeholder="blur", Astro's <Image> and Cloudinary's e_blur all emit a 20-40 byte base64 or a dominant colour you can drop straight into this markup. The CSS above does not change — only where the placeholder string comes from.

Make it yours

  • Swap the SVG blobs for a base64 LQIP (background-image:url(data:image/webp;base64,…)) — same classes, sharper approximation.
  • A single dominant colour is often enough: replace the SVG with background:oklch(0.55 0.12 250) and keep the blur off.
  • Tune the reveal: 800ms feels responsive, 1200ms feels cinematic, above 1600ms feels broken.
  • Change aspect-ratio to match your art direction — 16/9 for hero video stills, 4/5 for editorial portrait, 1/1 for avatars and product tiles.
  • Add @starting-style if you insert the <img> dynamically, so the entry transition runs on first paint.
  • Keep the blurred layer mounted at opacity:0 rather than removing it — a re-render then does not re-trigger a flash.

Gotchas — read before shipping

  • No aspect-ratio (or width/height attributes) means guaranteed CLS. The blur is cosmetic; the box is the performance fix.
  • filter: blur() on a large element creates a compositor layer. Do not stack a dozen blur-ups above the fold on low-end mobile.
  • Cached images fire decode() instantly and the fade never appears — that is correct behaviour, not a bug. Do not add an artificial delay in production.
  • loading="lazy" on an above-the-fold hero delays your LCP. Use fetchpriority="high" there instead.
  • Blur the placeholder, never the real image — animating a filter on the photo re-rasterises every frame.
  • If the image 404s, make sure the placeholder stays visible. Fading it out on error leaves an empty box.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 88+.

aspect-ratio: Chrome 88, Safari 15, Firefox 89. filter: blur() is universal. HTMLImageElement.decode() needs Chrome 64 / Safari 15.4 / Firefox 118 and is wrapped in try/catch so older engines fall through to the load event. color-mix() is gated behind @supports.

Techniques used in this demo

Search CodeFronts

Loading…