Feature 314 Feedback

Skeleton Loading โครงร่างระหว่างโหลด

Grey shapes with the same size as the real cards appear first, so the page does not jump when data arrives.

Live demo

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

What to notice

  • The skeleton cards copy the real card's layout and size.
  • A shimmer gradient moves across them with one CSS animation.
  • Products come from SQL Server through /api/products with an artificial delay.

Try it

Source code

6 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/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/templates/demos/l314.html The grid starts empty and is filled by demos.js
<div class="product-grid" id="skGrid" aria-busy="true" aria-live="polite"></div>
src/main/resources/static/css/app.css Skeleton: grey blocks with a moving shimmer
.skeleton {
  background: var(--surface-3); border-radius: .45rem; position: relative; overflow: hidden; color: transparent !important;
}
.skeleton::after {
  content: ""; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 65%, transparent), transparent);
  animation: shimmer 1.3s infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
.sk-line { height: .8rem; margin: .45rem 0; }
.sk-line.w-60 { width: 60%; } .sk-line.w-40 { width: 40%; } .sk-line.w-80 { width: 80%; }
src/main/resources/static/js/demos.js A skeleton card has the same boxes as the real card
const skeletonCard = () => `
  <article class="product-card" aria-hidden="true">
    <div class="pc-img skeleton" style="border-radius:0"></div>
    <div class="pc-body">
      <div class="skeleton sk-line w-40"></div>
      <div class="skeleton sk-line w-80"></div>
      <div class="pc-row"><div class="skeleton sk-line w-40" style="width:3.5rem"></div><div class="skeleton sk-line" style="width:2.5rem"></div></div>
    </div>
  </article>`;
src/main/resources/static/js/demos.js Show skeletons first, swap in the real cards when the data arrives
demos[314] = () => {
  const grid = $('#skGrid');
  async function load() {
    const useSkeleton = $('#skOn').checked, delay = $('#skDelay').value, t0 = performance.now();
    grid.setAttribute('aria-busy', 'true');
    grid.innerHTML = useSkeleton ? Array.from({ length: 8 }, skeletonCard).join('') : '';
    const data = await api(`/api/products?page=${Math.floor(Math.random() * 10)}&size=8&delay=${delay}`);
    grid.innerHTML = data.items.map(productCard).join('');
    grid.setAttribute('aria-busy', 'false');
    $('#skTime').textContent = `${data.items.length} products in ${Math.round(performance.now() - t0)} ms`;
  }
  $('#skReload').addEventListener('click', (e) => UI.withButton(e.currentTarget, load));
  load();
};

Are you sure?

Rename

↑↓ moveEnter openEsc close