GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Lazy Loading Images: 9 CSS Placeholder Patterns

Among nine lazy loading images patterns, one computes a ratio to fade an image in, one computes timing to swap an iframe on click, and one computes idle delay

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine demos below fetch a real image, video, iframe, or webfont over the network — every "load" you see is a timer, an observer callback, or an idle-time budget standing in for one. When you adapt any of these into a real page, the placeholder-to-real swap is exactly where a genuine <img src>, a fetch, a real <iframe src>, or an actual document.fonts.ready promise belongs; nothing here calls Math.random or pulls in an external library either, so every run of a given pattern behaves the same way twice in a row.

Nine Small Decisions About When Content Is Worth Loading

Every pattern on this page answers the same question with a different input: is now the moment to spend the cost of loading something? Pattern 03 asks a scroll position, pattern 09 asks the browser's own idle clock, pattern 07 asks whether a visitor clicked at all. Below each pattern's code, the description names the one function carrying that decision so you can find it again once you've copied the file out of the zip.

01Text That Swaps a Fallback Font for the Real One via Lazy Loading

A font-ready value is computed to show a fallback typeface first, then swap the letterforms smoothly once the real font finishes loading. Each character gets its own transition delay computed from its index times STAGGER_STEP_MS, so the swap ripples left to right instead of snapping all at once. In production this stands in for the real document.fonts.ready promise or a font-display: swap boundary — nothing here downloads an actual webfont.

Text That Swaps a Fallback Font for the Real One via Lazy Loading
Open in a new tab

01 · Text That Swaps a Fallback Font for the Real One via Lazy Loading — Measured area 5.237% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_STEP_MSFOUT_DELAY_MSRESET_DELAY_MS
  var TEXT = 'Lazy-load headline';
  var STAGGER_STEP_MS = 45;
  var FOUT_DELAY_MS = 700;
  var HOLD_MS = 1800;
  var RESET_DELAY_MS = 900;
  var timer = null;

  // calc layer: use the character index value to compute the swap delay, so the spring overshoot spreads in order.
  var chars = [];
  for (var i = 0; i < TEXT.length; i += 1) {
    var span = document.createElement('span');
    span.className = 'lff__char';
    span.textContent = TEXT[i] === ' ' ? ' ' : TEXT[i];
    span.style.transitionDelay = (i * STAGGER_STEP_MS) + 'ms';
    head.appendChild(span);
    chars.push(span);
  }

  function resetState() {
    root.classList.remove('lff--loaded');
    for (var i = 0; i < chars.length; i += 1) { chars[i].classList.remove('lff__char--loaded'); }
    badge.textContent = 'Showing fallback font';
  }

02Background Chosen by a Screen-width Value via Lazy Loading

A screen-width value is computed to pick a lower- or higher-resolution background, transitioning from a placeholder color to the chosen background. crossfadeIn divides elapsed time by a duration to get a 0–1 progress value and rewrites blur and opacity from that ratio on every animation frame. Swap WIDTH_NARROW/WIDTH_WIDE for real image URLs, and swap the placeholder-color transition for a real background-image load event, when adapting this.

Background Chosen by a Screen-width Value via Lazy Loading
Open in a new tab

02 · Background Chosen by a Screen-width Value via Lazy Loading — Measured area 38.076% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

BREAKPOINT_PXCROSSFADE_MSWIDTH_NARROW
  var BREAKPOINT_PX = 240;
  var WIDTH_NARROW = 180;
  var WIDTH_WIDE = 300;
  var LOAD_DELAY_MS = 480;
  var CROSSFADE_MS = 480;
  var CYCLE_MS = 2200;

  var lastTier = null;
  var raf = null;
  var loadTimer = null;

  // calc layer: divide the elapsed time value by the total transition time value to get a progress ratio,
  // then use that ratio to recompute blur radius and opacity every frame.
  function crossfadeIn(el, duration) {
    var start = performance.now();
    if (raf) { cancelAnimationFrame(raf); }
    function step(now) {
      var progress = Math.min((now - start) / duration, 1);
      el.style.opacity = String(progress);
      el.style.filter = 'blur(' + (6 * (1 - progress)) + 'px)';
      if (progress < 1) { raf = requestAnimationFrame(step); }
    }
    raf = requestAnimationFrame(step);
  }

03Image Fade-in Where a Scroll-position Value Computes an Intersection Ratio

A scroll-position value computes how much of the image overlaps the viewport, and once that ratio clears a threshold the placeholder fades into the real image. applyRatio recalculates blur radius and scale on every observation so the picture sharpens in step with how far it has scrolled into view, not in one jump. The INTERSECTION_THRESHOLD constant is the only line you'd touch to move that clearing point earlier or later.

Image Fade-in Where a Scroll-position Value Computes an Intersection Ratio
Open in a new tab

03 · Image Fade-in Where a Scroll-position Value Computes an Intersection Ratio — Measured area 36.04% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

INTERSECTION_THRESHOLDSTEPSapplyRatio
  var INTERSECTION_THRESHOLD = 0.4;
  var STEPS = [];
  for (var s = 0; s <= 20; s += 1) { STEPS.push(s / 20); }

  // calc layer: recompute blur radius and scale from the intersection ratio value on every observation —
  // the image sharpens in proportion to how much it overlaps the viewport. Past the threshold ratio, mark it loaded.
  function applyRatio(card, ratio) {
    var img = card.querySelector('.lfv__img');
    var placeholder = card.querySelector('.lfv__placeholder');
    var blur = 10 * (1 - ratio);
    var scale = 0.92 + 0.08 * ratio;
    img.style.opacity = String(ratio);
    img.style.filter = 'blur(' + blur.toFixed(1) + 'px)';
    img.style.transform = 'scale(' + scale.toFixed(3) + ')';
    placeholder.style.opacity = String(Math.max(0, 1 - ratio * 1.4));
    if (ratio >= INTERSECTION_THRESHOLD) {
      card.classList.add('lfv__card--loaded');
    }
    if (card === cards[Math.min(cards.length - 1, Math.round(lastMaxIndex))]) {
      badge.textContent = 'Intersection ratio ' + Math.round(ratio * 100) + '%';
    }
  }

04Avatar Where a Name Value Computes Initials Before Lazy Loading

A typed name value is computed into two initials shown in a round badge first, then that spot is swapped for the real profile photo once it's ready. getInitials slices the first two characters, and a second function sums the name's character codes to pick a badge color from PALETTE by hash, so the same name always lands on the same color. Real code would replace the badge with an <img> the moment a profile-photo fetch resolves.

Avatar Where a Name Value Computes Initials Before Lazy Loading
Open in a new tab

04 · Avatar Where a Name Value Computes Initials Before Lazy Loading — Measured area 1.731% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROW_STAGGER_MSLOAD_BASE_MSPALETTE
  var btn = document.getElementById('laiBtn');
  var NAMES = ['Kim Haneul', 'Lee Doyun', 'Park Seojun'];
  var PALETTE = ['lai__row--a', 'lai__row--b', 'lai__row--c', 'lai__row--d'];
  var ROW_STAGGER_MS = 260;
  var LOAD_BASE_MS = 320;
  var CYCLE_GAP_MS = 2400;

  // calc layer 1: slice the first two characters off the name string as initials.
  function getInitials(name) { return name.slice(0, 2); }

  // calc layer 2: sum the character codes of the name, then take that sum modulo the palette length to pick a badge color (hash).
  function colorHashClass(name) {
    var sum = 0;
    for (var i = 0; i < name.length; i += 1) { sum += name.charCodeAt(i); }
    return PALETTE[sum % PALETTE.length];
  }

05Gallery Where a Grid-position Value Computes the Reveal Order

A thumbnail's grid-position value computes its reveal delay, so placeholders swap for real photos in order starting from the top-left with a staggered gap. delayFor turns a cell's row and column into a Manhattan-distance delay, which is why the reveal fans out diagonally instead of row by row. With COLS fixed at 3, only the bottom-right cell ever waits the full three steps of STAGGER_STEP_MS.

Gallery Where a Grid-position Value Computes the Reveal Order
Open in a new tab

05 · Gallery Where a Grid-position Value Computes the Reveal Order — Measured area 20.573% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_STEP_MSSTART_DELAY_MSdelayFor
  var COLS = 3;
  var ROWS = 2;
  var STAGGER_STEP_MS = 90;
  var START_DELAY_MS = 200;
  var CYCLE_GAP_MS = 1600;

  var cells = [];
  for (var i = 0; i < COLS * ROWS; i += 1) {
    var cell = document.createElement('div');
    cell.className = 'lgs__cell';
    cell.innerHTML = '<div class="lgs__placeholder"></div><div class="lgs__photo lgs__photo--' + (i % 4) + '"></div>';
    grid.appendChild(cell);
    cells.push(cell);
  }

  // calc layer: grid position (row value + column value) computes the delay — a Manhattan-distance stagger.
  // top-left (row 0, col 0) swaps to the real photo first, bottom-right swaps latest.
  function delayFor(idx) {
    var row = Math.floor(idx / COLS);
    var col = idx % COLS;
    return START_DELAY_MS + (row + col) * STAGGER_STEP_MS;
  }

06Video Poster Swap Judged by a Playback Value via Lazy Loading

A playback-started value is computed to show only a poster image before play begins, handing the spot to the real video frame the instant that value changes. runBuffering runs the same progress-ratio loop as pattern 02, except it writes the ratio into a conic-gradient angle instead of blur, so the ring visibly completes before playback starts. No <video> element or real stream sits behind it — swap one in the instant buffering finishes.

Video Poster Swap Judged by a Playback Value via Lazy Loading
Open in a new tab

06 · Video Poster Swap Judged by a Playback Value via Lazy Loading — Measured area 21.261% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

BUFFER_DURATION_MSPLAY_HOLD_MSrunBuffering
  var BUFFER_DURATION_MS = 620;
  var PLAY_HOLD_MS = 1600;
  var CYCLE_GAP_MS = 700;

  var isPlaying = false;
  var raf = null;

  // calc layer: divide the elapsed time value by the total buffering time value to get a progress ratio,
  // then write that ratio into the conic-gradient angle variable every frame so the ring turns.
  function runBuffering(onDone) {
    var start = performance.now();
    frame.classList.add('lvp__frame--buffering');
    function step(now) {
      var progress = Math.min((now - start) / BUFFER_DURATION_MS, 1);
      ring.style.background = 'conic-gradient(from -90deg, #5b8cf8 ' + (progress * 360).toFixed(1) + 'deg, rgba(255,255,255,.18) 0)';
      if (progress < 1) {
        raf = requestAnimationFrame(step);
      } else {
        frame.classList.remove('lvp__frame--buffering');
        onDone();
      }
    }
    raf = requestAnimationFrame(step);
  }

07Iframe Where a Click Value Computes the Load Moment via Lazy Loading

A click value is computed so that a static notice sits in a map or social-embed slot before any click, and only on click does that slot become the real iframe. maxDistanceToCorner finds the farthest of the frame's four corners from the click point with the Pythagorean theorem, so the ripple that follows always finishes covering the whole frame from wherever you clicked. The real swap-in target here is an actual <iframe src>, never loaded until this click fires.

Iframe Where a Click Value Computes the Load Moment via Lazy Loading
Open in a new tab

07 · Iframe Where a Click Value Computes the Load Moment via Lazy Loading — Measured area 21.261% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIPPLE_DURATION_MSLOAD_DELAY_MSmaxDistanceToCorner
  var RIPPLE_DURATION_MS = 520;
  var LOAD_DELAY_MS = 340;
  var CYCLE_GAP_MS = 2400;

  var loaded = false;

  // calc layer: compute the farthest of the four frame corners from the click point (Pythagorean) as the
  // ripple's target radius, so a circle starting at the real click point ends up covering the frame.
  function maxDistanceToCorner(x, y, w, h) {
    var dTL = Math.sqrt(x * x + y * y);
    var dTR = Math.sqrt((w - x) * (w - x) + y * y);
    var dBL = Math.sqrt(x * x + (h - y) * (h - y));
    var dBR = Math.sqrt((w - x) * (w - x) + (h - y) * (h - y));
    return Math.max(dTL, dTR, dBL, dBR);
  }

08Below-fold Section Where a Scroll Value Computes a Threshold via Lazy Loading

A scroll value is computed against a set threshold, leaving a below-fold section empty until it's crossed, at which point real content fills it. checkThreshold compares the scroll container's scrollTop to SCROLL_THRESHOLD_PX on every scroll and wheel event and flips state only at the instant it's crossed, in either direction. A production version would replace the empty section with a fetch or a framework's lazy route boundary at that same crossing point.

Below-fold Section Where a Scroll Value Computes a Threshold via Lazy Loading
Open in a new tab

08 · Below-fold Section Where a Scroll Value Computes a Threshold via Lazy Loading — Measured area 16.765% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

SCROLL_THRESHOLD_PXAUTOSCROLL_STEP_PXcheckThreshold
  var SCROLL_THRESHOLD_PX = 86;
  var AUTOSCROLL_STEP_PX = 2.2;
  var CYCLE_GAP_MS = 1400;

  var filled = false;
  var userActive = false;

  // calc layer: compare the scroll container's scrollTop value against a fixed threshold (px) —
  // it flips to filled only the instant it crosses (stays empty before that).
  function checkThreshold() {
    var over = view.scrollTop >= SCROLL_THRESHOLD_PX;
    if (over && !filled) {
      filled = true;
      section.classList.add('lsr__section--filled');
    } else if (!over && filled) {
      filled = false;
      section.classList.remove('lsr__section--filled');
    }
  }

09Widget Where an Idle-time Value Computes a Delay via Lazy Loading

An idle-time value is computed for when the browser is free, leaving a heavy recommendation or ad widget as a placeholder until that time is available. tick accumulates the leftover time requestIdleCallback reports on each call until it reaches IDLE_BUDGET_MS, falling back to a capped setTimeout step if the browser never reports an idle window. That budget-then-swap approach is exactly where you'd hand off to a real ad-slot or recommendation-widget script once it clears.

Widget Where an Idle-time Value Computes a Delay via Lazy Loading
Open in a new tab

09 · Widget Where an Idle-time Value Computes a Delay via Lazy Loading — Measured area 17.406% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

IDLE_BUDGET_MSIDLE_TIMEOUT_MSIDLE_TICK_CAP_MS
  var accumulated = 0;
  var idleHandle = null;
  var running = false;

  // calc layer: accumulate the idle time remaining (timeRemaining) that requestIdleCallback hands back on
  // every callback, and only swap the widget slot for real content the instant the accumulated value reaches
  // the target budget (IDLE_BUDGET_MS).
  function tick(deadline) {
    if (!running) { return; }
    var got = deadline.didTimeout ? IDLE_TICK_CAP_MS : Math.min(deadline.timeRemaining(), IDLE_TICK_CAP_MS);
    accumulated = Math.min(IDLE_BUDGET_MS, accumulated + Math.max(got, 12));
    var pct = Math.round((accumulated / IDLE_BUDGET_MS) * 100);
    bar.style.width = pct + '%';
    sub.textContent = 'Securing idle time ' + pct + '%';
    if (accumulated >= IDLE_BUDGET_MS) {
      widget.classList.add('liw__widget--loaded');
      running = false;
    } else {
      idleHandle = ric(tick, { timeout: IDLE_TIMEOUT_MS });
    }
  }

A lazy load animation css Idea That Only Matters Once You Ship It

A lazy load animation css idea only earns its keep once you can point at a real page and say "that's where it's needed" — three of the nine above are worth naming. Pattern 02's screen-width background is the shape a hero section or an event page needs, since the visitor sees a placeholder color settle into the right-resolution image before anything else on the page competes for their attention. Pattern 07's click-to-load iframe fits a store-location map or a social-post embed exactly because those are the two slots most likely to sit unused below the fold — no map tiles or embed script loads until someone actually asks for it by clicking.

Pattern 09 belongs in the spot most pages get wrong: a sidebar ad widget or a related-product rail that would otherwise compete with the page's own content for the browser's attention during first paint. Letting requestIdleCallback decide when that widget is worth the cost, rather than loading it eagerly, is the same idea as patterns 02 and 07 applied to a slot nobody is looking at yet anyway.

Which Value Is Actually Doing the Deciding, Pattern by Pattern

Pattern Value it watches Recomputed via Demo replay input
01 font-ready state one setTimeout per swap click, keydown
02 screen width requestAnimationFrame loop click, keydown
03 scroll-driven intersection ratio IntersectionObserver callback scroll, wheel, touchstart
04 typed name string one setTimeout per row click
05 grid row + column index staggered setTimeout per cell click, keydown
06 playback-started flag requestAnimationFrame loop click
07 click coordinates requestAnimationFrame loop click, keydown
08 container scrollTop scroll/wheel event + requestAnimationFrame scroll, wheel, touchstart
09 accumulated idle time requestIdleCallback, setTimeout fallback click

Most lazy loading images css tutorials stop at the CSS transition and skip the code that decides when to add the loaded class — that decision is the whole difference between these nine patterns, not the fade itself, which is one line of opacity and filter in every one of them.

Where a Frame Loop Recomputes the Value Every Tick — 02, 06, 07, 08

02, 06, and 07 share the exact same progress-ratio loop — elapsed time divided by a duration, clamped to 1 — written into three different outputs: blur-and-opacity, a conic-gradient angle, and a ripple radius. 08 uses requestAnimationFrame only for its own autoplay scrolling in the demo, not for the threshold check itself, which is a plain comparison run on the scroll event.

Where a Single Timer or Observer Callback Is Enough — 01, 04, 05

01, 04, and 05 never touch requestAnimationFrame at all: a setTimeout fires once per character, row, or cell, and CSS transitions handle the motion between states on their own. This is the cheaper of the two approaches whenever the visible change is a single property crossing from one value to another, rather than a value that has to be recomputed continuously.

Where a Browser Signal Decides for You — 03, 09

03 and 09 are the two patterns that hand the decision to the browser instead of to a timer you wrote: IntersectionObserver reports the ratio, and requestIdleCallback reports the leftover time, and both patterns just react to whatever number comes back.

Three Numbers You Can Turn Before You Ship

Variant Item Constant Base → variant What changes
03 Image fade-in INTERSECTION_THRESHOLD 0.4 → 0.25 the image locks in as loaded after crossing the viewport sooner, at a smaller overlap
05 Gallery stagger STAGGER_STEP_MS 90 → 160 the diagonal reveal across the grid takes noticeably longer to finish
09 Idle widget IDLE_BUDGET_MS 260 → 420 the widget needs a larger sum of idle time before it swaps in, so it tends to wait through more callbacks

Each of these three variants is its own index.html inside the zip with exactly that one constant edited — nothing about the listeners, the reduced-motion branch, or the rest of the markup changes between a base pattern and its variant.

The Corner Case Every One of These Nine Breaks On

Every one of these nine assumes it gets to finish its own timing on its own schedule, and every one of them breaks the same way when a visitor interrupts that schedule mid-swap. Scroll away from pattern 03 while its ratio is still climbing, or click pattern 07's iframe a second time before the first ripple finishes, and you're relying on the code to cancel its own in-flight requestAnimationFrame or timer rather than letting two competing swaps race each other. Every pattern in this set does cancel the previous run before starting a new one — cancelAnimationFrame before a fresh raf = assignment, or a guard flag like loaded/filled/running before the callback body runs — but that single habit is also the first thing that quietly goes missing when someone copies the visible half of a pattern into a new project and skips the cleanup line.

The zip below carries all nine index.html files plus their three variants, unpacked with the password 794sbpwn typed in by hand rather than pasted, since a copied password from a page that also shows the answer defeats the point of asking at all.

Reduced-motion, and the Three Things It Turns Off

Every one of the nine patterns reads window.matchMedia('(prefers-reduced-motion: reduce)').matches once on load and branches on it, and the three items with the longest animated ramp show the difference most clearly. Pattern 01 skips the per-character stagger entirely and sets every letter to its loaded state at once instead of rippling left to right. Pattern 03 skips the blur-and-scale ramp applyRatio normally runs and jumps straight to the fully-cleared image the first time the ratio passes the threshold. Pattern 09's progress bar fills to its final width in one step rather than animating up through each idle callback's contribution, though the idle-time accounting underneath is unchanged — reduced motion turns off the animated display of a decision, never the decision itself.

A lazy loading ui design that never turns any of this off is a design that keeps animating for a visitor who told their operating system to stop.

Questions People Ask Before Opening the Zip

Why do items 05 and 09 move on so few of the 23 measured frames?

Both are one-shot loading transitions rather than continuous scenes — 05 finishes its diagonal stagger and 09 finishes filling its budget well before the measurement window ends, so most of the 23 frames after that point show the same settled, loaded state and correctly count as unchanged rather than as a defect in either pattern.

Is there a lazy load placeholder image pattern here simple enough to start with?

Pattern 01's fallback-font swap is the simplest lazy load placeholder image pattern in the set — one setTimeout, one CSS class toggle, and a transition delay computed per character — before you move on to the observer- and idle-callback-driven patterns later in the page.

Do any of these nine load a real image, video, or script from a URL?

No — every placeholder-to-real swap in this set is simulated with a timer, an observer callback, or an idle budget, and the point at which you'd point that same code at an actual <img src>, <video>, <iframe src>, or fetch call is named in each pattern's description above.

Sources: MDN — Intersection Observer API · MDN — Window.requestIdleCallback · MDN — img loading attribute · MDN — FontFaceSet · MDN — prefers-reduced-motion

Related reading: Infinite Scroll Loading: 9 Essential UI Patterns · Chart Loading Animation — 9 Computed Patterns · 9 Social Share Button Design Embed Widgets — the first swaps a spinner or skeleton for real rows using the same never-fetch-for-real demo rule as this post, the second computes its skeleton timing from measured data the same way pattern 05 computes its stagger from grid position, and the third shares this post's pattern 07 almost exactly — a static placeholder standing in for an embed until a visitor actually clicks it.

Enter the archive password

The password is inside this article. You will find it as you read.