Fix CLS Issues on Modx (Layout Shift) | OpsBlu Docs

Fix CLS Issues on Modx (Layout Shift)

Stabilize MODX layouts by sizing images in Chunk templates, preloading web fonts, and constraining Snippet-generated dynamic output.

Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load. Good CLS is critical for user experience and SEO.

Target: CLS under 0.1 Good: Under 0.1 | Needs Improvement: 0.1-0.25 | Poor: Over 0.25

For general CLS concepts, see the global CLS guide.

MODX-Specific CLS Issues

1. Images Without Dimensions

Images from TVs, chunks, or getResources without explicit dimensions cause layout shifts.

Problem: Images load and push content down as dimensions are determined.

Diagnosis:

  • Run PageSpeed Insights
  • Look for "Image elements do not have explicit width and height"
  • Use Chrome DevTools Performance → Record page load → Look for Layout Shift events

Solutions:

A. Add Dimensions to Template Images

Always specify width and height:

<!-- Before: No dimensions -->
<img src="[[*hero_image]]" alt="[[*pagetitle]]">

<!-- After: With dimensions -->
<img
  src="[[*hero_image:phpthumb=`w=1920&h=600&zc=1`]]"
  width="1920"
  height="600"
  alt="[[*pagetitle]]"
>

B. Calculate Aspect Ratio for TVs

For variable image sizes:

<!-- Responsive with aspect ratio -->
<div style="position: relative; width: 100%; padding-bottom: 56.25%;"> <!-- 16:9 ratio -->
  <img
    src="[[*hero_image:phpthumb=`w=1920&h=1080&zc=1`]]"
    alt="[[*pagetitle]]"
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;"
  >
</div>

Or use modern aspect-ratio CSS:

<img
  src="[[*hero_image:phpthumb=`w=1920&h=1080&zc=1`]]"
  alt="[[*pagetitle]]"
  style="aspect-ratio: 16 / 9; width: 100%; height: auto; object-fit: cover;"
>

C. getResources with Image Dimensions

In chunk template used by getResources:

Chunk: blogPost.tpl

<article>
  <h2>[[+pagetitle]]</h2>

  <!-- Bad: No dimensions -->
  <img src="[[+tv.thumbnail]]" alt="[[+pagetitle]]">

  <!-- Good: With dimensions -->
  <img
    src="[[+tv.thumbnail:phpthumb=`w=800&h=450&zc=1`]]"
    width="800"
    height="450"
    alt="[[+pagetitle]]"
    loading="lazy"
  >

  [[+introtext]]
</article>

In template:

[[!getResources?
  &parents=`5`
  &tpl=`blogPost.tpl`
  &includeTVs=`thumbnail`
  &limit=`10`
]]

D. Use Placeholder Images

For images that load slowly:

<img
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 450'%3E%3C/svg%3E"
  data-src="[[*hero_image:phpthumb=`w=800&h=450&zc=1`]]"
  width="800"
  height="450"
  alt="[[*pagetitle]]"
  class="lazyload"
>

2. Dynamic Content from Snippets

Snippets that load content after page render cause layout shifts.

Problem: getResources or custom snippets loading content dynamically.

Solutions:

A. Reserve Space for Snippet Output

<!-- Bad: No reserved space -->
[[!getResources? &parents=`5` &limit=`10`]]

<!-- Better: Reserved space with min-height -->
<div style="min-height: 500px;">
  [[!getResources?
    &parents=`5`
    &limit=`10`
    &tpl=`blogPost.tpl`
  ]]
</div>

B. Cache Snippets to Prevent Shifts

<!-- Uncached: Loads after page, may cause shift -->
[[!getResources? &parents=`5` &limit=`10`]]

<!-- Cached: Rendered with page, no shift -->
[[getResources? &parents=`5` &limit=`10`]]

Note: Only cache when content doesn't need to be real-time.

C. Use Skeleton Screens

For uncached content that must load dynamically:

<!-- Skeleton loader -->
<div class="skeleton-container [[!getResources? &parents=`5` &limit=`1`:isempty=``:else=`hidden`]]">
  <div class="skeleton-item"></div>
  <div class="skeleton-item"></div>
  <div class="skeleton-item"></div>
</div>

<!-- Actual content -->
<div class="content-container">
  [[!getResources? &parents=`5` &limit=`3` &tpl=`blogPost.tpl`]]
</div>

<style>
  .skeleton-item {
    height: 200px;
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
    margin-bottom: 1rem;
    border-radius: 8px;
  }

  @keyframes loading {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
  }

  .hidden { display: none; }
</style>

3. Font Loading Shifts

Custom fonts loading after render cause text reflow.

Problem: Flash of Invisible Text (FOIT) or Flash of Unstyled Text (FOUT).

Solutions:

A. Use font-display: swap

/* In template or CSS chunk */
@font-face {
  font-family: 'CustomFont';
  src: url('[[++assets_url]]fonts/custom-font.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately, swap when loaded */
  font-weight: 400;
  font-style: normal;
}

B. Preload Critical Fonts

<!-- In template head -->
<link
  rel="preload"
  href="[[++assets_url]]fonts/custom-font.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>

C. Match Fallback Font Metrics

Use similar fallback fonts to minimize shift:

body {
  font-family: 'CustomFont', Arial, sans-serif;
  /* Adjust line-height and letter-spacing to match custom font */
  line-height: 1.5;
  letter-spacing: -0.01em;
}

D. Use System Fonts

Eliminate font loading shifts entirely:

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif;
}

4. Ads and Embeds

Third-party content (ads, social embeds, maps) loading without reserved space.

Problem: Embedded content pushes existing content down.

Solutions:

A. Reserve Space for Embeds

<!-- YouTube embed with reserved space -->
<div style="position: relative; width: 100%; padding-bottom: 56.25%;"> <!-- 16:9 -->
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
    frameborder="0"
    allowfullscreen
  ></iframe>
</div>

B. Set Min-Height for Ad Slots

<!-- Ad container with reserved space -->
<div class="ad-container" style="min-height: 250px; width: 300px;">
  [[!adSnippet]]
</div>

C. Load Embeds on User Interaction

Prevent shifts by loading on click:

<div class="video-placeholder" style="width: 100%; padding-bottom: 56.25%; position: relative; background: #000; cursor: pointer;"
  <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white;">
    ▶ Click to load video
  </div>
</div>

<script>
function loadVideo(el) {
  el.innerHTML = '<iframe src="https://www.youtube.com/embed/VIDEO_ID" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" frameborder="0" allowfullscreen></iframe>';
}
</script>

5. JavaScript-Modified Layouts

JavaScript in templates or chunks that modify DOM causes shifts.

Problem: Scripts manipulating layout after page load.

Solutions:

A. Avoid DOM Manipulation After Load

// Bad: Modifies DOM after load
document.addEventListener('DOMContentLoaded', function() {
  document.querySelector('.header').style.height = '100px';
});

// Better: Set in CSS
.header { height: 100px; }

B. Use CSS for Responsive Behavior

/* Instead of JavaScript resize listeners */
.sidebar {
  width: 300px;
}

@media (max-width: 768px) {
  .sidebar {
    width: 100%;
  }
}

C. Calculate Dimensions Server-Side

In MODX snippet instead of client-side:

<?php
// Calculate dimensions in snippet
$imageWidth = 800;
$imageHeight = 450;

$output = '<img src="' . $image . '" width="' . $imageWidth . '" height="' . $imageHeight . '" alt="">';

return $output;

6. Uncached Snippets with Variable Output

Snippets that produce different heights on each load.

Problem: Cart counts, user info, dynamic pricing causing variable heights.

Solutions:

A. Set Minimum Heights

<div class="user-info" style="min-height: 60px;">
  [[!userInfo]]
</div>

B. Use Fixed Layouts

.cart-count {
  display: inline-block;
  width: 24px; /* Fixed width prevents shift */
  text-align: center;
}

C. Load Below Fold

Place dynamic content below viewport:

<!-- Above fold: Static content with no shifts -->
<header>[[*pagetitle]]</header>
<div class="hero">[[*hero_image]]</div>

<!-- Below fold: Dynamic content -->
<section>
  [[!dynamicContent]]
</section>

7. Navigation Menus

JavaScript-based menus that render after page load.

Problem: Menu items appearing/disappearing causing layout shifts.

Solutions:

A. Server-Side Menu Generation

Use Wayfinder or pdoMenu (cached):

<!-- Cached menu, no shift -->
[[Wayfinder?
  &startId=`0`
  &level=`2`
  &outerTpl=`navOuter`
  &rowTpl=`navRow`
]]

B. Reserve Space for Menus

.nav-container {
  min-height: 60px; /* Reserve space for menu */
}

.nav-menu {
  /* Menu styles */
}

C. Hide Until Ready

For JavaScript menus:

.nav-menu {
  opacity: 0;
  transition: opacity 0.3s;
}

.nav-menu.ready {
  opacity: 1;
}
// Show when ready
document.addEventListener('DOMContentLoaded', function() {
  const menu = document.querySelector('.nav-menu');
  // Initialize menu...
  menu.classList.add('ready');
});

MODX Template Best Practices

1. Use Consistent Chunk Structures

Create chunks with fixed dimensions:

Chunk: productCard.tpl

<div class="product-card" style="height: 400px;">
  <img
    src="[[+tv.image:phpthumb=`w=300&h=200&zc=1`]]"
    width="300"
    height="200"
    alt="[[+pagetitle]]"
    loading="lazy"
  >
  <h3>[[+pagetitle]]</h3>
  <p class="price">$[[+tv.price]]</p>
</div>

2. Template Variable (TV) Output Filters

Ensure consistent output:

<!-- Bad: Variable length -->
[[*description]]

<!-- Better: Truncated to consistent length -->
[[*description:ellipsis=`150`]]

3. Grid Layouts with Fixed Heights

<div class="grid" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
  [[getResources?
    &parents=`5`
    &limit=`9`
    &tpl=`productCard.tpl`
  ]]
</div>

CSS:

.product-card {
  height: 400px; /* Fixed height prevents shifts */
  overflow: hidden;
}

4. Lazy Loading Below Fold

<!-- Above fold: eager loading, no lazy -->
<img src="[[*hero_image:phpthumb=`w=1920`]]" width="1920" height="600" alt="" loading="eager">

<!-- Below fold: lazy loading -->
<img src="[[*gallery_image:phpthumb=`w=800`]]" width="800" height="600" alt="" loading="lazy">

Testing & Monitoring

Measure CLS

Chrome DevTools:

  1. Open DevTools (F12)
  2. Performance tab
  3. Click Record
  4. Reload page
  5. Stop recording
  6. Look for red Layout Shift bars in timeline
  7. Click to see which elements shifted

Web Vitals Extension:

  • Install Web Vitals extension
  • Visit page
  • Check CLS score in extension popup

PageSpeed Insights:

  • Test URL at pagespeed.web.dev
  • Review CLS score in both Lab and Field data
  • Expand to see which elements contribute

Debug Layout Shifts

Identify shifting elements:

// Add to template temporarily
let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value;
      console.log('Layout Shift:', entry.value, entry.sources);
    }
  }
}).observe({type: 'layout-shift', buffered: true});

window.addEventListener('load', () => {
  setTimeout(() => {
    console.log('Total CLS:', cls);
  }, 2000);
});

Test Different Resources

Test CLS across:

  • Homepage
  • Article/blog pages
  • Product pages (if e-commerce)
  • Pages with getResources
  • Pages with dynamic snippets

Quick Wins Checklist

Immediate CLS improvements:

  • Add width/height to all images in templates
  • Add dimensions to images in getResources chunks
  • Use font-display: swap for custom fonts
  • Reserve space for embedded content (videos, ads)
  • Cache snippets that don't need real-time data
  • Set min-height on containers with dynamic content
  • Use aspect-ratio CSS for responsive images
  • Load JavaScript with defer to prevent DOM manipulation
  • Test with Chrome DevTools Performance tab
  • Verify CLS < 0.1 in PageSpeed Insights

Common CLS Culprits in MODX

Issue Impact Fix Priority
Images without dimensions High Highest - Add width/height
Uncached getResources Medium High - Cache when possible
Font loading Medium High - Use font-display: swap
Dynamic ads/embeds High Medium - Reserve space
JavaScript menus Low Low - Use server-side rendering

When to Hire a Developer

Consider hiring if:

  • CLS consistently over 0.25 after basic fixes
  • Complex JavaScript interactions causing shifts
  • Need custom lazy-loading implementation
  • Template architecture needs restructuring
  • Dynamic content requirements conflict with CLS goals

Find MODX developers: MODX Professional Directory

Next Steps

For general CLS optimization strategies, see CLS Optimization Guide.