---
title: 'Skeleton Screens'
source: 'https://academia.sh/en/courses/accessible-patterns/skeleton-screens'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:52+00:00'
license: 'CC BY-SA 4.0'
---

# Skeleton Screens

The specification of the skeleton screen; its place in the accessibility tree, computing cumulative layout shift with a session window for multi-region loading, and the cost of the skeleton's promised structure turning out wrong.

The previous lesson's indicator said "I am working" but did not hold the place of the
content to come. What appears on screen when the catalog page opens is not a single
section: the top bar, the filter panel, the result list, and the recommendations arrive
at separate times. Each time one arrives, everything beneath it is pushed down.

A **skeleton screen** is a loading view made of gray boxes that hold the place of coming
content in advance. The Loading and Empty States lesson measured it through a single
unsteady element, and the skeleton's row count was determined by the visible area. This
lesson writes the same pattern as a specification and deepens the measurement in two
directions: what the skeleton is in the accessibility tree, and how shift **accumulates**
when several regions arrive at separate times.

## What It Solves, When Not to Use It

The skeleton does two jobs. It reserves space and so prevents layout shift, and it shows
the structure of coming content in advance, making the wait feel shorter. The second job
is a perceptual effect and is hard to measure; the first job can be turned into a number,
and it is what the decision rests on.

There are three situations where it should not be used:

- **When the chance of no results is high.** If the skeleton draws nine rows and three
  results arrive, the user briefly believes there are nine results; if no results arrive
  at all, the skeleton has plainly lied.
- **When the structure is unknown.** A skeleton commits to the place of the boxes it
  draws. If the row height or field count of the coming content varies, the commitment
  does not hold, and shift occurs again.
- **When the wait is below the perception threshold.** A skeleton drawn for content that
  arrives in a hundred milliseconds is itself a source of flicker.

## Native Element First, ARIA Second

The skeleton has no counterpart in markup; the boxes are purely visual placeholders. This
is also the critical side of the specification: **skeleton elements must not exist in the
accessibility tree.**

| Part | Source |
|---|---|
| Skeleton boxes | `aria-hidden="true"` — decorative elements carrying no meaning |
| Refreshed region | `aria-busy="true"`, removed once content arrives |
| Status announcement | A separate polite region: "Loading results" -> "12 records listed" |
| Focus | The region itself is not replaced; the focus inside it is preserved |

The reasoning is direct. When skeleton boxes appear in the tree, they announce that
something readable exists on screen, when in fact none of them carry text. The
decorative-element rule from the Icon Usage lesson applies exactly here: visuals that
carry no meaning are hidden.

The wait information therefore comes not from the skeleton but from the **status
region**. The region is placed when the page is built, receives a short sentence when
loading begins, and is updated with the result count when content arrives. The two rules
from the Notification Banners lesson apply here too: the region must exist beforehand and
must announce the final state.

## Shift Is Not a Single Event, It Is a Sum

The layout shift score was defined in the Loading and Empty States lesson: the product of
the ratio of the affected area to the viewport area and the ratio of the distance
traveled to the largest dimension. The Frontend Quality course added that this score is
summed within a **session window**: a window begins with the first shift and closes on a
gap longer than one second or once five seconds have elapsed; **cumulative layout shift**
is the largest of the windows.

The script below loads a four-region catalog page through four separate skeleton setups
and splits each setup's shifts into windows.

```js
// skeleton.mjs — cumulative layout shift and session windows in multi-region loading

const VIEWPORT = { w: 1280, h: 800 };
const FOOTER = 80;

// The page is made of regions in visual order. Each region switches to its real height at its own moment.
const LAYOUT = [
  { name: "warning banner",  at: 6500, real: 64,  skeleton: 0   },
  { name: "top bar",         at: 400,  real: 96,  skeleton: 96  },
  { name: "filter panel",    at: 700,  real: 120, skeleton: 120 },
  { name: "result list",     at: 1500, real: 648, skeleton: 648 },  // 9 rows x 72 px
  { name: "recommendations", at: 2200, real: 240, skeleton: 240 },
];

// Scenarios: each region's starting (placeholder) height and its real height.
const SCENARIO = {
  "no skeleton": (b) => ({ start: b.name === "result list" ? 120 : 0, end: b.real }),
  "partial skeleton": (b) => ({ start: b.name === "result list" ? b.skeleton : 0, end: b.real }),
  "full skeleton": (b) => ({ start: b.skeleton, end: b.real }),
  "skeleton 9 rows, 3 results": (b) => ({
    start: b.skeleton,
    end: b.name === "result list" ? 3 * 72 : b.real,
  }),
};

function shifts(scenario) {
  const build = SCENARIO[scenario];
  const height = LAYOUT.map((b) => build(b).start);
  const events = LAYOUT.map((b, i) => ({ i, at: b.at, name: b.name, end: build(b).end }))
    .sort((a, b) => a.at - b.at);

  const result = [];
  for (const o of events) {
    const delta = o.end - height[o.i];
    if (delta === 0) { height[o.i] = o.end; continue; }

    // Top edge and total height of the content below the region that changed
    const topEdge = height.slice(0, o.i).reduce((a, b) => a + b, 0);
    const beforeBottomEdge = topEdge + height[o.i];
    const belowTotal = height.slice(o.i + 1).reduce((a, b) => a + b, 0) + FOOTER;
    const afterBottomEdge = beforeBottomEdge + delta;

    // Impact fraction: the union of the shifted content's visible area before and after
    const visible = (top) => [Math.max(0, Math.min(VIEWPORT.h, top)),
                               Math.max(0, Math.min(VIEWPORT.h, top + belowTotal))];
    const [o1, o2] = visible(beforeBottomEdge);
    const [s1, s2] = visible(afterBottomEdge);
    const impactFraction = (Math.max(o2, s2) - Math.min(o1, s1)) / VIEWPORT.h;
    const distanceFraction = Math.abs(delta) / Math.max(VIEWPORT.w, VIEWPORT.h);

    result.push({ at: o.at, name: o.name, delta, impactFraction, distanceFraction, score: impactFraction * distanceFraction });
    height[o.i] = o.end;
  }
  return result;
}

// Session window: begins with the first shift, closes on a gap longer than 1 s or once 5 s have elapsed.
function windows(shiftList) {
  const w = [];
  for (const k of shiftList) {
    const last = w[w.length - 1];
    if (last && k.at - last.lastAt <= 1000 && k.at - last.startAt <= 5000) {
      last.lastAt = k.at; last.total += k.score; last.count++;
    } else w.push({ startAt: k.at, lastAt: k.at, total: k.score, count: 1 });
  }
  return w;
}

let first = true;
for (const name of Object.keys(SCENARIO)) {
  const k = shifts(name);
  if (!first) console.log("");
  first = false;
  console.log(name);
  console.log("    at  region             delta  impact  distance  shift score");
  for (const s of k)
    console.log(
      `  ${String(s.at).padStart(4)}  ${s.name.padEnd(18)} ${((s.delta > 0 ? "+" : "") + s.delta + " px").padStart(7)} ` +
        `${s.impactFraction.toFixed(4).padStart(8)} ${s.distanceFraction.toFixed(4).padStart(10)} ${s.score.toFixed(4).padStart(13)}`,
    );
  const w = windows(k);
  for (const win of w)
    console.log(`  session window ${win.startAt}-${win.lastAt} ms: ${win.count} shift${win.count === 1 ? "" : "s"}, total ${win.total.toFixed(4)}`);
  const cls = w.length ? Math.max(...w.map((win) => win.total)) : 0;
  console.log(`  cumulative layout shift: ${cls.toFixed(4)}  (accepted good ceiling 0.100) -> ${cls <= 0.1 ? "passes" : "FAILS"}`);
}
```

```
no skeleton
    at  region             delta  impact  distance  shift score
   400  top bar             +96 px   0.3700     0.0750        0.0278
   700  filter panel       +120 px   0.4000     0.0938        0.0375
  1500  result list        +528 px   0.5800     0.4125        0.2392
  2200  recommendations    +240 px   0.0000     0.1875        0.0000
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 400-2200 ms: 4 shifts, total 0.3045
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.3045  (accepted good ceiling 0.100) -> FAILS

partial skeleton
    at  region             delta  impact  distance  shift score
   400  top bar             +96 px   1.0000     0.0750        0.0750
   700  filter panel       +120 px   0.8800     0.0938        0.0825
  2200  recommendations    +240 px   0.0000     0.1875        0.0000
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 400-700 ms: 2 shifts, total 0.1575
  session window 2200-2200 ms: 1 shift, total 0.0000
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.1575  (accepted good ceiling 0.100) -> FAILS

full skeleton
    at  region             delta  impact  distance  shift score
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.0500  (accepted good ceiling 0.100) -> passes

skeleton 9 rows, 3 results
    at  region             delta  impact  distance  shift score
  1500  result list        -432 px   0.4600     0.3375        0.1553
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 1500-1500 ms: 1 shift, total 0.1553
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.1553  (accepted good ceiling 0.100) -> FAILS
```

The four setups produce four separate results, and the difference between them does not
reduce to a single number.

**The no-skeleton setup** produces 0.3045, three times the threshold. As expected, the
largest share belongs to the result list: 528 pixels intruding all at once accounts for
0.2392 points on its own.

**The partial skeleton** brings the total down to 0.1575, but by less than expected. The
reason is in the first two rows of the table: once a skeleton is placed only on the list,
the shifts of the top bar and the filter panel **grow** — the impact fraction climbs from
0.3700 to 1.0000, from 0.4000 to 0.8800. The reason is clear: there is now a long skeleton
block beneath them to push. Placing a skeleton on one region does not cheapen the shifts
of the regions above it; it makes them more expensive. When the skeleton is applied
partially, the gain stays partial too.

**The full skeleton** zeroes out every shift in the first window. What remains is only the
warning banner arriving in the sixth second; because it falls into a separate session
window, it is not added to the total, and the cumulative value stays at 0.0500. Counting
windows separately is decisive here: if the same five shifts had fallen into a single
window, the result would have exceeded the threshold.

The **wrong commitment** scenario measures the skeleton's own risk. When a nine-row
skeleton is drawn and three results arrive, the list **shortens** by 432 pixels and
produces 0.1553 points. The direction of the shift does not matter; content jumping
upward produces just as many points as being pushed down. A firm rule follows from this:
**the skeleton's row count is set equal to the result count when it is known; when it is
not known, the skeleton is chosen small enough that any shift is bearable.**

The recommendations region produces zero points in every scenario. The reason is that the
content below that region falls outside the viewport; a shift that is not visible on
screen does not enter the metric. This does not mean lower regions can be left without a
skeleton — on a smaller screen the same region enters the visible area and produces
points.

## Measurable Constraints

**Cumulative layout shift of 0.100.** This is the accepted good ceiling, and the
computation is done with session windows as above. This is not a success criterion but a
performance threshold, though its accessibility consequence is direct: a shifting layout
leads a user about to press a button to press the wrong one.

**2.2.2 Pause, Stop, Hide.** If the shimmer motion sweeping across the skeleton boxes
lasts longer than five seconds, it must be able to be stopped. When reduced motion is
signaled as a preference, the shimmer is removed entirely; the skeleton does its job as
flat gray boxes too.

**2.3.1 Three Flashes.** The shimmer must not repeat more than three times per second.

**1.4.11 Non-Text Contrast does not apply** to skeleton boxes, because the boxes carry no
meaning and are hidden from the tree. Even so, a skeleton faint enough to blend into the
surface leads the user to think the page is empty; this is a design decision, not a
criterion.

**4.1.3 Status Messages.** Loading and completion notifications come from the polite
status region, not from the skeleton.

## Common Mistakes and How to Recognize Them

**The skeleton appears in the tree.** When the boxes are not hidden, the screen does not
count as empty, but nothing readable is found either. How to recognize it: count the text
elements on the page during loading; if skeleton boxes are counted, hiding is incomplete.

**The skeleton is placed on only one region.** The gain comes out lower than expected and
the shifts of the regions above it grow. How to recognize it: measure shifts region by
region; if the points above the region with a skeleton have grown, this is the reason.

**The skeleton does not mimic the real row height.** When the boxes are drawn shorter
than the real row, shift still occurs once the content arrives. How to recognize it:
compare the skeleton's height against the settled row's height.

**A late-arriving banner is inserted at the top of the page.** When the warning banner
enters at the top in the sixth second, the whole page shifts down. How to recognize it:
check whether space for late-arriving sections was set aside from the start; if not, the
banner should be placed outside the flow rather than on top of the content.

## Summary

- A skeleton screen holds the place of coming content; its measurable job is preventing
  layout shift, and its effect on perceived speed is a secondary, unmeasurable-in-itself
  gain.
- Skeleton boxes carry no meaning and are hidden from the accessibility tree; wait
  information comes from a separate polite status region, not the skeleton.
- Shift is not a single event but a sum: it is totaled within session windows, and the
  cumulative value is the largest of the windows; a late-arriving shift does not change
  the total if it falls into a separate window.
- A partial skeleton enlarges the shifts of the regions above it; in the measurement, the
  top bar's impact fraction climbs from 0.3700 to 1.0000. The gain is complete only once
  every region reserves space.
- The row count the skeleton draws is a commitment; drawing nine rows and delivering
  three results produces a shift of 0.1553 points, and the direction of the shift does
  not affect the score.
- Shimmer motion is bound to the 2.2.2 and 2.3.1 criteria; with reduced motion preferred,
  the skeleton does its job without shimmer too.

## Next Step

The skeleton rested on the assumption that content **would arrive**. Once loading ends,
two situations remain where that assumption does not hold: no records arriving at all,
and the request failing. The two look similar on screen — an empty area and a sentence —
but tell the user entirely different things and require entirely different recovery
paths. The next lesson writes these two states as a specification: which role and which
priority to use, where focus goes, how the error summary binds to fields, and it measures
the length of the recovery path by key count.
