<div id="load-trigger-wrapper">
      <div id="image-container"></div>
      <div id="load-trigger"></div>
    </div>
    <div id="bottom-panel">
      Images:
      &nbsp;<b><span id="image-count"></span>
      &nbsp;</b>/
      &nbsp;<b><span id="image-total"></span></b>
    </div>
html,
body {
  width: 100%;
  margin: 0;
  height: 100%;
  display: flex;
  flex-direction: column;
}

#load-trigger-wrapper {
  overflow: auto;
  scrollbar-gutter: stable;
  height: 100%;
  width: 100%;
  display: flex;
  flex-direction: column;
}

#bottom-panel {
  height: 40px;
  display: flex;
  align-items: center;
  padding-left: 8px;
  font-size: 1.1em;
  border-top: 1px solid #d0d0d0;
}

#load-trigger {
  margin-top: auto;
}

#image-container {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  width: 100%;
  flex-grow: 1;
  position: relative;
}

.image,
.skeleton-image {
  height: 50vh;
  border-radius: 5px;
  border: 1px solid #c0c0c0;
  /* Three per row, with space for margin */
  width: calc((100% / 3) - 24px);

  /* Initial color before loading animation */
  background-color: #eaeaea;

  /* Grid spacing */
  margin: 8px;

  /* Fit into grid */
  display: inline-block;
}

.skeleton-image {
  transition: all 200ms ease-in;

  /* Contain ::after element with absolute positioning */
  position: relative;

  /* Prevent overflow from ::after element */
  overflow: hidden;
}

.skeleton-image::after {
  content: "";

  /* Cover .skeleton-image div*/
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;

  /* Setup for slide-in animation */
  transform: translateX(-100%);

  /* Loader image */
  background-image: linear-gradient(90deg, rgba(255, 255, 255, 0) 0, rgba(255, 255, 255, 0.2) 20%, rgba(255, 255, 255, 0.5) 60%, rgba(255, 255, 255, 0));

  /* Continue animation until image load*/
  animation: load 1s infinite;
}

@keyframes load {
  /* Slide-in animation */
  100% {
    transform: translateX(100%)
  }
}
const imageContainer = document.getElementById('image-container');
const imageCountText = document.getElementById('image-count');
const imageTotal = document.getElementById('image-total');
const loader = document.getElementById('loader');
const loadTrigger = document.getElementById('load-trigger');

const imageLimit = 50;
const loadLimit = 9;
let imagesShown = 0;
const storedImageCount = 15;
const imageClass = 'image';
const skeletonImageClass = 'skeleton-image';

let throttleTimer;
const throttleTime = 1000;

imageTotal.innerText = imageLimit;

const observer = detectScroll();

function getRandomColor() {
  const h = Math.floor(Math.random() * 360);

  return `hsl(${h}deg, 90%, 85%)`;
}

function getColors(count) {
  const result = [];
  let randUrl = undefined;

  while (result.length < count) {
    // Prevent duplicate images
    while (!randUrl || result.includes(randUrl)) {
      randUrl = getRandomColor();
    }

    result.push(randUrl);
  }

  return result;
}

// This function would make requests to an image server
function loadMoreImages() {
  const newImageElements = [];
  const amountToLoad = Math.min(loadLimit, imageLimit - imagesShown);
  for (let i = 0; i < amountToLoad; i++) {
    const image = document.createElement('div');

    // Indicate image load
    image.classList.add(imageClass, skeletonImageClass);

    // Include image in container
    imageContainer.appendChild(image);

    // Store in temp array to update with actual image when loaded
    newImageElements.push(image);
  }

  // Update image count
  imagesShown += amountToLoad;
  imageCountText.innerText = imagesShown;

  // Simulate delay from network request
  setTimeout(() => {
    const colors = getColors(amountToLoad);
    for (let i = 0; i < colors.length; i++) {
      const color = colors[i];
      newImageElements[i].classList.remove(skeletonImageClass);
      newImageElements[i].style.backgroundColor = color;
    }
  }, 1500);

  if (imagesShown === imageLimit) {
    observer.unobserve(loadTrigger);
  }
}

function detectScroll() {
  const observer = new IntersectionObserver(
    (entries) => {
      for (let entry of entries) {
        if (entry.isIntersecting) {
          throttle(() => {
            loadMoreImages();
          }, throttleTime);
        }
      }
    },
    // Set "rootMargin" because of #bottom-panel
    { rootMargin: '-30px' }
  );

  observer.observe(loadTrigger);

  return observer;
}

function throttle(callback, time) {
  // Prevent additional calls until timeout elapses
  if (throttleTimer) {
    console.log('throttling');
    return;
  }
  throttleTimer = true;

  setTimeout(() => {
    callback();

    // Allow additional calls after timeout elapses
    throttleTimer = false;
  }, time);
}
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.