Image Grid Skeleton
2 × 2 grid of 260 × 260 px: four 125 × 125 px cells, 12 px radius, 1.8 s ease-in-out shimmer on ::after. A 0 / 0.15 / 0.30 / 0.45 s cascade carried by the --skel-delay custom property, set on each cell and inherited by the pseudo-element — the four tiles light up one after another.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Loaders has 29 effects, including 4 free. Explore the category →
3 usage examples



How it works
Structure. .grid-skel is a two-column CSS grid (grid-template-columns: 1fr 1fr, gap: 10px, width: 260px): each cell is (260 − 10) ÷ 2 = 125 px wide. Height is set by aspect-ratio: 1/1 → 125 × 125 px. The full grid is 260 × 260 px. Each cell is a div.skel-bone.grid-skel-img: fill rgba(255,255,255,.08), border-radius: 12px (overriding the 6 px from the base class), position: relative and overflow: hidden to contain the pseudo-element. No JS: the effect is entirely CSS.
Shimmer. The animation runs on ::after (absolute, inset: 0, content: ""). Its background is a linear-gradient(90deg, transparent 0, rgba(255,255,255,.1) 50%, transparent 100%) with background-size: 200% 100% — the gradient is twice the cell's width. The shimmerSlide keyframe moves background-position from -200% 0 to 200% 0: the bright stripe enters from the left, sweeps across the cell and exits right. Duration: 1.8 s ease-in-out, infinite. The 10% opacity peak stays subtle against the 8% grey fill.
Cascade — and why it goes through a custom property. The shipped HTML sets style="--skel-delay: .15s", .3s and .45s on three of the four cells (the first has no attribute), and .skel-bone::after declares animation-delay: var(--skel-delay, 0s). This detour is required: the animation lives on the pseudo-element, and an animation-delay set on an element does not propagate to its ::after — animation properties are not inherited, each pseudo-element has its own. A CSS custom property, on the other hand, is inherited by pseudo-elements: var(--skel-delay, 0s) reads the value written on the parent cell and falls back to 0s when none is set. Measured with the getAnimations() API: the four pseudo-elements report delay = 0 / 150 / 300 / 450 ms and four distinct background-position values at the same instant — the cascade is clearly visible. Counter-check: a style="animation-delay: .15s" written directly on the cell leaves all four ::after at delay=0ms and at the same position — perfect sync, no cascade.
Accessibility
- prefers-reduced-motion not handled. Confirmed: no
@media (prefers-reduced-motion: reduce)rule in the sold CSS. All four shimmers run continuously regardless of the system preference. Add@media (prefers-reduced-motion: reduce) { .skel-bone::after { animation: none } }— the grey cells remain visible and still convey a loading state, without motion. - A skeleton screen is a functional loading indicator, not pure decoration. The
.grid-skelcontainer should carryrole="status"andaria-label="Loading images…"so a screen reader announces the waiting state. Remove these attributes and replace witharia-hidden="true"once the real content is inserted. - The cells are empty
divs: no text, no icon. Setaria-hidden="true"on each.skel-boneto keep the accessibility tree clean, with only the container's announcement. - Integration. The grid has a fixed
width(260 px): on narrow viewports, addmax-width: 100%to.grid-skel—grid-template-columns: 1fr 1frandaspect-ratio: 1/1will adapt automatically.
Browser compatibility
Pure CSS, no JS. Requires CSS Grid (2019), aspect-ratio (2021), inset (2021), custom properties var() (2017) and @keyframes/animation (2012).
Without aspect-ratio (Safari 14 and older): cells collapse to zero height and become invisible. Fix: add height: 125px to .grid-skel-img. Without inset: replace with top:0;right:0;bottom:0;left:0 on .skel-bone::after.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="grid-skel">
<div class="skel-bone grid-skel-img"></div>
<div class="skel-bone grid-skel-img" style="--skel-delay: .15s"></div>
<div class="skel-bone grid-skel-img" style="--skel-delay: .3s"></div>
<div class="skel-bone grid-skel-img" style="--skel-delay: .45s"></div>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Shimmer duration (CSS — .skel-bone::after { animation: 1.8s ease-in-out infinite … shimmerSlide }) |
1.8 s | Speed of the bright stripe sweep. 1 s = snappy shimmer; 2.5 s = slow, discreet pulse. |
Grid width (CSS — .grid-skel { width: 260px }) |
260 px | Drives cell size: (width − gap) ÷ 2. 200px → ~95 × 95 px; 340px → ~165 × 165 px. Add max-width: 100% for fluid layouts. |
Gap (CSS — .grid-skel { gap: 10px }) |
10 px | Gutter between the four cells. 6 px to tighten; 16 px for a card-like spacing. |
Border radius (CSS — .grid-skel-img { border-radius: 12px }) |
12 px | 12 px for softly rounded corners. 0 for sharp squares (full-bleed photos); 50% for circles (4-up avatar grid). |
Cell fill (CSS — .skel-bone { background: rgba(255,255,255,.08) }) |
rgba(255,255,255,.08) | Resting fill visible between shimmer passes. On a light background, switch to rgba(0,0,0,.08). |
Shimmer intensity (CSS — rgba(255,255,255,.1) in the .skel-bone::after gradient) |
rgba(255,255,255,.1) | .06 = barely visible; .18 = clearly defined stripe. Keep a ≥ 2:1 ratio with the cell fill value. |
Cascade (HTML — style="--skel-delay: .15s" on each cell, read by .skel-bone::after { animation-delay: var(--skel-delay, 0s) }) |
0 / 0.15 / 0.30 / 0.45 s (the first cell has no attribute: 0s fallback) |
Offset between cells lighting up. Remove the attributes → synchronous shimmer; .25s, .5s, .75s → a more pronounced cascade. Always go through the custom property: an animation-delay written directly on the cell does not propagate to ::after, whereas a custom property is inherited by it. |
FAQ
shimmerSlide animation is declared on ::after, and a pseudo-element has its own animation properties: an animation-delay set on the parent cell is not passed down — animation-* properties are not inherited. CSS custom properties are: .skel-bone::after { animation-delay: var(--skel-delay, 0s) } therefore reads the value written in style="--skel-delay: .15s" on the cell, and falls back to 0s for the first one, which has no attribute. Counter-check with getAnimations(): with style="animation-delay: .15s" on the cell instead, all four pseudo-elements stay at delay=0ms and shimmer in perfect sync. If you generate the grid in a loop, write style="--skel-delay: …" on each cell with an increasing value..skel-bone.grid-skel-img elements (9 total) and set grid-template-columns to 1fr 1fr 1fr. For a 4 × 2, add 4 elements and use 1fr 1fr 1fr 1fr. Adjust width accordingly — e.g. 370px for four ~85 px columns with a 10 px gap. aspect-ratio: 1/1 handles the height automatically. Extend the cascade by setting --skel-delay to increasing values on the new cells (.6s, .75s…), or remove the attributes for a synchronous shimmer.