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();
};