Skip to content

Lazy-load skeleton

A block wrapped in i:dynamic with lazy="auto" or lazy="manual" is rendered empty in the HTML — its content only arrives later over AJAX. Until then it occupies no space, so a product listing loaded this way pops into the page and pushes everything below it down (layout shift).

The pattern below reserves that space with a skeleton placeholder. It is pure CSS and needs no placeholder markup at all: the placeholder is drawn with pseudo-elements on the deferred block itself, so the only template change is a class on i:dynamic.

What the platform renders

The lazy attribute changes what the first HTML response contains:

lazyFirst paintLoads when
(omitted)Content rendered server-side
auto<div data-dynamic-content="…" data-dynamic-lazy="true"></div> — emptyThe block scrolls into the viewport (IntersectionObserver)
manual<div data-dynamic-content="…"></div> — emptyOnly when JS calls $(element).reload()

Loading then goes through three states, and the CSS keys off the first two:

  1. Before loading — the element is genuinely :empty. The whole block body, the template's newlines and indentation included, is compiled behind an isLazyLoadedContent condition that the first render passes as false, so not even a whitespace text node survives.
  2. While loadingreload() writes its own loader into the block: <div class="loader-bubble-4" style="padding-top:0;height:…px;width:…px"><div/><div/><div/><div/></div>. The measurements are taken from the block before the swap, which for an empty block means 0 × 0. The same inline width and height are also written onto the block itself.
  3. After loading — the block is replaced by the re-rendered element, which is neither empty nor holds a loader.

The two hooks

One rule covers both waiting states:

css
.lazy-block:empty,
.lazy-block > .loader-bubble-4 {
    /* placeholder styles */
}

Neither half works alone. :empty stops matching the instant the loader is inserted, which would drop the placeholder mid-load and reopen the gap; matching only the loader leaves the whole pre-load phase — usually the longer half — unstyled. Together they cover the wait end to end, and because both are styled identically the transition between them is invisible.

Nothing is needed for the finished state. Step 3 hands over a fresh element that matches neither selector, so the placeholder disappears on its own — including when a hide-empty container found no products and rendered nothing.

Undoing the platform's inline styles

The loader is meant to be a spinner inside a box that already has a size. Here the box has none, so its own styling has to be neutralised — otherwise the placeholder collapses to the 0 × 0 the platform measured:

css
.lazy-block:empty,
.lazy-block > .loader-bubble-4 {
    display: block;
    position: static;      /* the global loader style positions it absolutely */
    width: auto !important;   /* inline attribute — needs !important */
    height: auto !important;
    overflow: visible;
    text-align: left;
}

/* the loader's four animated bubbles */
.lazy-block > .loader-bubble-4 > div {
    display: none;
}

/* the inline size is written on the block too */
.lazy-block:has(> .loader-bubble-4) {
    width: auto !important;
    height: auto !important;
}

Drawing the placeholder

::before and ::after carry the shimmer as a background, and a stack of mask layers cuts it into tiles. One ::after draws a whole row of product cards — a square image plus three text lines of decreasing width:

css
.lazy-block:empty::after,
.lazy-block > .loader-bubble-4::after {
    content: '';
    display: block;
    box-sizing: content-box;

    /* percentage padding resolves against the width, so the tile is square */
    padding-top: var(--sk-tile);
    height: var(--sk-text-h);

    background-color: var(--sk-color);
    background-image: linear-gradient(
        90deg,
        var(--sk-color) 25%,
        var(--sk-highlight) 37%,
        var(--sk-color) 63%
    );
    background-size: 400% 100%;
    animation: skeleton-shimmer 1.4s ease infinite;

    mask-image:
        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1'/%3E%3C/svg%3E"),
        linear-gradient(#000, #000),
        linear-gradient(to right, #000 60%, transparent 60%),
        linear-gradient(to right, #000 40%, transparent 40%);
    mask-repeat: space no-repeat, space no-repeat, space no-repeat, space no-repeat;
    mask-size:
        var(--sk-tile) auto,
        var(--sk-tile) var(--sk-line),
        var(--sk-tile) var(--sk-line),
        var(--sk-tile) var(--sk-line);
    mask-position:
        0 0,
        0 calc(100% - (var(--sk-text-h) - 1 * (var(--sk-gap) + var(--sk-line)))),
        0 calc(100% - (var(--sk-text-h) - 2 * (var(--sk-gap) + var(--sk-line)))),
        0 calc(100% - (var(--sk-text-h) - 3 * (var(--sk-gap) + var(--sk-line))));
}

Two tricks carry the layout:

  • The 1 × 1 SVG rectangle is a mask whose height is computed from its width, so mask-size: var(--sk-tile) auto produces a square whatever the column width turns out to be. mask-repeat: space then tiles it across the row and distributes the leftover space as the gutter — one layer draws every image in the row.
  • Percentage padding-top resolves against the width, which is what makes the pseudo-element as tall as the square images it contains, without knowing the pixel width.

More rows, or a section heading above the row, are more layers on the same stack: ::before can take the heading plus the first row while ::after takes the second.

Geometry via custom properties

Write the mask stack once and let per-breakpoint custom properties move it. Only the numbers change, never the layer list:

css
.lazy-block:empty,
.lazy-block > .loader-bubble-4 {
    --sk-color: #ededeb;
    --sk-highlight: #f4f4f2;

    --sk-cols: 2;
    --sk-gutter: 0.625rem;
    /* same arithmetic as a grid cell: (100% + gutter) / columns - gutter */
    --sk-tile: calc((100% + var(--sk-gutter)) / var(--sk-cols) - var(--sk-gutter));

    --sk-text-h: 10.75rem;   /* name + suffix + price + rating */
    --sk-line: 0.875rem;
    --sk-gap: 0.75rem;
}

@media (min-width: 40em) {
    .lazy-block:empty,
    .lazy-block > .loader-bubble-4 {
        --sk-cols: 4;
        --sk-gutter: 1.25rem;
        --sk-text-h: 9.5rem;
    }
}

A layer can also be switched off through a custom property: give it mask-size: var(--sk-head-bar, 0 0) and it draws nothing until a variant sets --sk-head-bar. That keeps one shared stack for blocks whose rows differ.

Getting the numbers right matters — a placeholder of the wrong height only moves the layout shift from load time to swap time. Take the gutter and row margin from the real grid cell, and measure the text height on the rendered listing at each breakpoint (temporarily drop the lazy attribute to see it). Remember the block's own box as well: padding that in the loaded state comes from the container inside the block has to be reproduced on the placeholder, or the swap will jump.

Safety valve

If the request never completes, the placeholder must not sit there forever:

css
.lazy-block:has(> .loader-bubble-4) {
    animation: skeleton-expire 15s step-end forwards;
}

@keyframes skeleton-expire {
    to {
        max-height: 0;
        padding: 0;
        margin: 0;
        overflow: hidden;
        visibility: hidden;
    }
}

step-end means no interpolation: nothing moves until the deadline, then the placeholder collapses in one step. It animates max-height rather than height because height is pinned with !important above and an animation would not beat that.

The timer hangs off the loader, so it only starts once loading is actually under way — a block far below the fold waits as long as it takes for the visitor to reach it, and only then gets its 15 seconds.

:has() is used for the safety valve alone. In a browser without it, the two waiting states still work; only the timeout is lost.

Reduced motion

css
@media (prefers-reduced-motion: reduce) {
    .lazy-block:empty::before,
    .lazy-block:empty::after,
    .lazy-block > .loader-bubble-4::before,
    .lazy-block > .loader-bubble-4::after {
        animation: none;
    }
}

In the template

Nothing but the class — a marker for the pattern and, optionally, a variant class carrying that block's geometry. The example is the cross-sell listing under the cart (kosik.tpl), simplified:

twig
<i:dynamic variables="cart" class="lazy-block lazy-block--cart" lazy="auto">
    <i:list-container type="product" source="{ 'recommendCart': true, 'limit': 4 }" hide-empty>
        <h2 class="section__title">{% trans 'Mohlo by se vám hodit' %}</h2>
        <div data-role="list" class="listing grid-x xsmall-up-2 medium-up-4"></div>
    </i:list-container>
</i:dynamic>

Because there is no placeholder markup, there is nothing to keep in sync with the real card, nothing to hide from screen readers, and no second copy of the grid classes to update when the listing changes.

When to use it

Only for i:dynamic blocks with lazy="auto" or lazy="manual" — a plain i:dynamic is rendered server-side and leaves no gap to fill.

The cart cross-sell above is the typical case, and it is worth separating the two decisions behind it. The listing is wrapped in i:dynamic because recommendations depend on the current cart contents and have to be re-rendered whenever the cart changes; lazy is added on top because the section sits below the fold, so the query can wait until the visitor scrolls to it. The skeleton then pays for that deferral by holding the space in the meantime.

Anything expensive and below the fold is a candidate; see Performance.