How to Design Loading States Without Layout Shifts

Reserve space for content while it loads so skeletons and fallbacks match the final layout, keeping the page from jumping when data arrives.

6 min read

Loading states should reserve the space the final content will occupy, so the page does not jump when the data arrives. Layout shifts happen when a placeholder is shorter than the content it replaces, or when new content pushes everything below it down.

Why layout shifts happen

When a loading placeholder is smaller than the real content, the real content shoves the page down the moment it renders. Browsers measure this as Cumulative Layout Shift, and it makes a page feel broken even when it loads fast. The fix is always the same: make the placeholder the same size as the thing it stands in for.

Reserve space for images and media

Images are the most common cause. Give every image explicit width and height, or an aspect ratio, so the browser allocates the box before the file loads.

App.jsxApp.jsx
export default function Avatar({ src, name }) {
  return (
    <img
      src={src}
      alt={name}
      width={48}
      height={48}
      className="rounded"
    />
  );
}

The browser reserves a 48 by 48 box immediately, so the image popping in later moves nothing. Modern browsers derive the aspect ratio from those two attributes on their own. For responsive images, set aspect-ratio in CSS and let the width flex.

Match the skeleton to the content

A skeleton should copy the shape of the content it stands in for. Same row height, same spacing, same width. A list skeleton is a column of bars with the same height and gap as the final rows.

App.jsxApp.jsx
function PostsSkeleton() {
  return (
    <ul className="skeleton-list" aria-hidden="true">
      <li />
      <li />
      <li />
    </ul>
  );
}

Each li gets a fixed height and the same gap as a real post row in CSS. Keep the bars the same width as real content too, so nothing reflows horizontally.

The aria-hidden keeps the decorative bars out of the accessibility tree. When the real list replaces the skeleton, the page height does not change.

Make the skeleton the Suspense fallback so React shows it while the content loads:

App.jsxApp.jsx
<Suspense fallback={<PostsSkeleton />}>
  <Posts />
</Suspense>

Because the skeleton matches the list height, the reveal is a same-size swap instead of a jump. For how Suspense picks reveal moments, see React Suspense explained.

Do not inject above the viewport

Reserve space below existing content, or hold the shift in place. If a loading state adds a banner or a row above the fold, everything under it jumps.

New rows should append at the bottom of an existing list, never above the item the user is reading. Prefer inline placeholders that occupy the same box as the content they replace.

A skeleton is only the loading branch of a full async UI, so pair it with distinct empty and error states. For skeletons that announce progress accessibly, see accessible loading states, skeletons, and spinners in React.

Rune AI

Rune AI

Key Insights

  • Reserve space for images with width, height, or aspect-ratio.
  • Make skeletons match the final content height and spacing.
  • Use the skeleton as the Suspense fallback.
  • Do not inject new content above the viewport while loading.
  • Distinguish loading from empty and error states.
RunePowered by Rune AI

Frequently Asked Questions

Why does my page jump when a list finishes loading?

The skeleton is smaller than the final content. Give the skeleton the same height and spacing as the real rows so the swap changes nothing.

Should I set width and height on every image?

Yes, or use CSS aspect-ratio. The browser then reserves the box before the image loads, which prevents the shift.

Conclusion

Loading states should reserve the space the real content will occupy. Reserve image boxes, match skeletons to final dimensions, and keep the skeleton as the Suspense fallback.