Lazy-Loaded Carousel — HTML CSS JS Snippet

Lazy-Loaded Carousel · Cards · Plain HTML, CSS & JS · Live preview

What's included

Features

Slides only load their real content when genuinely about to be needed, not all upfront on page load
IntersectionObserver with an expanded rootMargin pre-loads the active slide's immediate neighbors ahead of time
A load-tracking map guarantees each slide is only ever loaded once, no matter how many times it's revisited
A visible placeholder-to-content swap and status log make the lazy-loading behavior observable, not just theoretical
The pattern generalizes directly to real <img> elements — swap the simulated delay for a real load event
Standard next/prev/dot navigation, unaffected by which slides have or haven't finished loading

About this UI Snippet

Lazy-Loaded Carousel — Loading Just Ahead of the Click, Not All Upfront

Screenshot of the Lazy-Loaded Carousel snippet rendered live

A carousel with ten heavy images usually loads all ten the instant the page loads, even though a visitor might only ever see the first two. This snippet defers that work: every slide starts as a plain gray placeholder, and its real content only loads the moment that slide is genuinely about to matter — either because it's the currently active one, or because it's sitting just outside the viewport where a viewer is about to scroll or click to it.

A generous rootMargin turns "about to be needed" into a real signal

The IntersectionObserver here uses rootMargin: '0px 100% 0px 100%' — expanding its effective viewport by a full container-width on both the left and right. That means a slide is reported as "intersecting" not just when it's literally on screen, but when it's within one carousel-width of being on screen — exactly capturing "the neighbor you're about to navigate to," so its load kicks off *before* you click next, not after.

A load-tracking map instead of re-fetching on every visit

loaded is a plain object keyed by slide index — ensureLoaded() checks it first and does nothing if that index has already started loading, even if the observer fires again for the same slide later (e.g. scrolling back and forth). That guard is what makes this safe to call from both the observer callback *and* directly from render() on every navigation, without ever double-fetching the same slide's content.

Simulated latency, standing in for a real fetch

fakeLoad() returns a Promise that resolves after a short randomized delay — a stand-in for a real <img>'s load event or a fetch call, deliberately visible enough that the placeholder-to-content swap reads as genuine asynchronous loading, not an instant synchronous render.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the IntersectionObserver uses an expanded rootMargin ('0px 100% 0px 100%') instead of the default viewport bounds, and how that specific margin value relates to pre-loading a slide's immediate neighbors before they're navigated to. It's also worth asking the assistant to adapt fakeLoad to use real <img> elements with actual src assignment and load-event resolution, or to add a visible loading spinner inside the placeholder while a slide's content is in flight.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a lazy-loading carousel in plain HTML, CSS, and vanilla JavaScript where each slide's real content only loads when that slide is about to be needed, using IntersectionObserver — no library.

Requirements:
- Several slide elements stacked absolutely within a viewport, each starting in a placeholder state (a plain neutral-colored box) with its real content (represented by a data attribute or similar, standing in for a real image URL) not yet applied.
- A "loaded" tracking structure (e.g. an object or Set keyed by slide index) that records which slides have already had their real content applied, checked before starting any load so a slide's content-loading logic can only ever run once per slide, no matter how many times loading is triggered for it.
- An asynchronous load function (standing in for a real image fetch or img.load event, using a Promise that resolves after a short delay) that, once it resolves for a given slide, swaps that slide's placeholder into its real content state with a visible CSS transition, not an instant snap.
- A single IntersectionObserver watching every slide, configured with a rootMargin significantly larger than the default zero margin (expanding the effectively-observed area by roughly a full container-width to either side) so that a slide is detected and begins loading its content once it comes within about one carousel-width of being visible — not only once it is already fully on screen.
- The currently active slide must always be guaranteed to have its loading triggered as well (in case the observer hasn't yet fired for it), independent of the observer's own detection.
- Standard previous/next buttons and dynamically generated indicator dots for navigation, with Left/Right arrow key support, all functioning correctly regardless of which slides have or haven't finished loading their content.
- A visible status indicator on the page reporting which slide most recently finished loading, so the lazy-loading behavior is observable rather than purely internal.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Source Code

<div class="llc-wrap">
  <div class="llc-track" id="llcTrack">
    <div class="llc-slide" data-bg="#6366f1,#4338ca" data-icon="🎧"><div class="llc-placeholder"></div></div>
    <div class="llc-slide" data-bg="#0ea5e9,#0369a1" data-icon="📷"><div class="llc-placeholder"></div></div>
    <div class="llc-slide" data-bg="#ec4899,#9d174d" data-icon="⌚"><div class="llc-placeholder"></div></div>
    <div class="llc-slide" data-bg="#10b981,#047857" data-icon="🎮"><div class="llc-placeholder"></div></div>
    <div class="llc-slide" data-bg="#f59e0b,#b45309" data-icon="🔊"><div class="llc-placeholder"></div></div>
  </div>
  <div class="llc-controls">
    <button class="llc-btn" id="llcPrev" aria-label="Previous">‹</button>
    <div class="llc-dots" id="llcDots"></div>
    <button class="llc-btn" id="llcNext" aria-label="Next">›</button>
  </div>
  <p class="llc-log" id="llcLog">Loaded: none yet</p>
</div>

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSThe first slide shows a brief gray placeholder, then loads in with its icon and color.
  2. 2
    Watch the log lineIt reports exactly which slide just finished loading, in real time.
  3. 3
    Click nextThe slide you land on is often already loaded, since its neighbor status triggered a pre-load earlier.
  4. 4
    Click through all fiveNotice each one only ever loads once, even if you navigate back and forth repeatedly.
  5. 5
    Swap in real imagesReplace fakeLoad's setTimeout with an actual <img> element whose src is set only inside ensureLoaded, resolving on its load event.

Real-world uses

Common Use Cases

Image-heavy product or portfolio carousels
Avoid loading every high-resolution image upfront when a visitor may only ever see the first couple.
Long testimonial or content carousels
Defer loading avatar images, embeds, or rich content until a slide is actually about to be seen.
Performance-sensitive landing pages
Cut initial page weight significantly on pages where a carousel is not the very first thing visible.
Learning the IntersectionObserver pre-load pattern
A clear example of rootMargin used to trigger work slightly before an element is actually visible.

Got questions?

Frequently Asked Questions

Replace the body of fakeLoad with code that creates or updates a real <img> element's src attribute and resolves the returned Promise on that image's load (and error) event, instead of a setTimeout — everything else in the pattern (the observer, the loaded map, ensureLoaded) works unchanged.

The default would only trigger a load once a slide is already visible — by the time a user clicks next, there'd be a visible loading delay. Expanding the observed area by a full container-width in each direction (100%) means a slide starts loading while it's still just off-screen, so it's often already loaded by the time it becomes active.

The loaded object is checked at the very top of ensureLoaded and set to true before the async load even starts — every subsequent call for that same index, whether from the observer firing again or from render(), sees it's already been claimed and does nothing further.

Yes — set root to the carousel's own scrollable ancestor (if the carousel sits inside its own scroll container) instead of null (the viewport) so the intersection calculation is relative to that container rather than the whole page.

Arrows and dots are real labeled buttons operable via Left/Right arrow keys; for real images, ensure meaningful alt text is set at the same time the src is set, so a screen reader announces accurate content once loading completes.