Feature 324 Content

Lazy Load โหลดเมื่อต้องการ

Images load only when they come near the screen, and the product list loads the next page as you scroll.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Lazy images loaded 0 / 48

Each picture has loading="lazy" and waits 400 ms on the server, so you can see it arrive. Scroll the box slowly.

Infinite list requests 0 rows 0

When the grey row at the bottom comes into view, the next page (12 products) is fetched from SQL Server.

Loading more…

What to notice

  • loading=lazy for images, IntersectionObserver for the infinite list.
  • Each scroll step asks Spring Data for one Page (OFFSET ... FETCH NEXT).
  • A counter shows how many requests were really made.

Try it

Source code

7 parts, read from the files in this project
src/main/java/com/study/uiux/web/ProductApi.java GET /api/products?page=0&size=12&delay=800
@GetMapping("/products")
public PageResult<ProductView> page(@RequestParam(defaultValue = "0") int page,
                                    @RequestParam(defaultValue = "12") int size,
                                    @RequestParam(defaultValue = "0") int delay) throws InterruptedException {
    DemoApi.pause(delay); // a slow network, so the skeleton is visible
    Page<Product> result = products.findAllByOrderById(PageRequest.of(Math.max(page, 0), Math.clamp(size, 1, 48)));
    return new PageResult<>(result.map(ProductApi::view).getContent(), result.getNumber(), result.getSize(),
            result.getTotalElements(), result.isLast());
}
src/main/java/com/study/uiux/repo/ProductRepository.java Spring Data paging: one SQL page per scroll step
// SQL Server: ... ORDER BY p.id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
Page<Product> findAllByOrderById(Pageable pageable);
src/main/resources/db/migration/V1__schema.sql product table
CREATE TABLE product (
    id          INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_product PRIMARY KEY,
    name        NVARCHAR(120)  NOT NULL,
    category    NVARCHAR(40)   NOT NULL,
    price       DECIMAL(10,2)  NOT NULL,
    stock       INT            NOT NULL,
    rating      DECIMAL(3,1)   NOT NULL,
    art_seed    INT            NOT NULL,
    description NVARCHAR(400)  NOT NULL,
    created_at  DATETIME2(0)   NOT NULL CONSTRAINT df_product_created DEFAULT SYSUTCDATETIME()
);
CREATE INDEX ix_product_category ON product (category, name);
src/main/resources/db/migration/V1__schema.sql gallery_photo table
CREATE TABLE gallery_photo (
    id       INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_gallery_photo PRIMARY KEY,
    title    NVARCHAR(100) NOT NULL,
    art_seed INT           NOT NULL,
    width    INT           NOT NULL,
    height   INT           NOT NULL
);
src/main/resources/templates/demos/l324.html loading="lazy" plus a fixed aspect ratio: no layout jump
<div class="scroll-box">
  <div class="gallery" id="lazyGallery">
    <figure th:each="p : ${photos}">
      <img th:src="@{/art/{s}.svg(s=${p.artSeed},w=480,h=360,delay=400)}" loading="lazy" decoding="async" alt="" width="480" height="360">
      <figcaption th:text="${p.title}">Title</figcaption>
    </figure>
  </div>
</div>
src/main/resources/static/css/app.css Gallery: the image box keeps its size before the picture arrives
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr)); gap: .75rem; }
.gallery figure { margin: 0; border-radius: var(--radius); overflow: hidden; position: relative; background: var(--surface-3); aspect-ratio: 4 / 3; }
.gallery img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .5s ease, transform .4s var(--ease); }
.gallery img.loaded { opacity: 1; }
.gallery figure:hover img { transform: scale(1.04); }
.gallery figcaption { position: absolute; left: 0; right: 0; bottom: 0; padding: 1.2rem .6rem .45rem; font-size: .75rem; color: #fff; background: linear-gradient(transparent, rgb(0 0 0 / .65)); }
.gallery button { all: unset; display: block; width: 100%; height: 100%; cursor: zoom-in; }
.gallery button:focus-visible { outline: 3px solid var(--ring); outline-offset: -3px; }
.infinite-sentinel { display: flex; justify-content: center; padding: 1.25rem; color: var(--text-3); font-size: .85rem; gap: .6rem; align-items: center; }
.scroll-box { max-height: 32rem; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; background: var(--surface-2); }
src/main/resources/static/js/demos.js IntersectionObserver loads the next page when the sentinel appears
let page = 0, last = false, busy = false, requests = 0;
const grid = $('#infGrid'), sentinel = $('#infSentinel');
async function more() {
  if (busy || last) return;
  busy = true;
  const data = await api(`/api/products?page=${page}&size=12&delay=500`);
  $('#infRequests').textContent = ++requests;
  grid.insertAdjacentHTML('beforeend', data.items.map(productCard).join(''));
  $('#infRows').textContent = $$('.product-card', grid).length;
  page++; last = data.last; busy = false;
  if (last) { sentinel.innerHTML = `${icon('check-circle')} All ${data.total} products loaded.`; io.disconnect(); }
  else if (sentinel.getBoundingClientRect().top < $('#infBox').getBoundingClientRect().bottom + 200) more(); // still visible
}
const io = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) more(); }, { root: $('#infBox'), rootMargin: '200px' });
io.observe(sentinel);

Are you sure?

Rename

↑↓ moveEnter openEsc close