Jump to content

ICANNWiki:Documentation/CLS

This is a subpage of ICANNWiki:Documentation describing a reusable pattern for preventing Cumulative Layout Shift (CLS) in client-rendered widgets. The guidance is written to help any MediaWiki site (and similar platforms) avoid layout jumps, with an ICANNWiki case study included for concrete examples. Maintained by Mark W. Datysgeld.

MediaWiki Rendering Priority (General Reference)

When diagnosing layout or UI stability issues, think of a rendering priority stack. Earlier layers shape what the browser paints first; later layers should respect the space and structure already defined.

MediaWiki Rendering Priority (General Reference)

When diagnosing layout or UI stability issues, treat the page as (1) server-side DOM construction, then (2) ResourceLoader-delivered CSS/JS behaviors, then (3) late/third-party content. The key operational goal is: layout anchors should exist in the delivered HTML and have CSS-reserved space before any JS enhances them.

MediaWiki Rendering Priority (General Reference)

When diagnosing layout or UI stability issues, treat the page as a priority stack. Earlier layers define the DOM shell and layout anchors; later layers should enhance what already exists rather than inventing structure late.

Layer (earliest → latest) Responsibility Reference Operational notes
Server-side HTML (Page chrome + Wrappers) MediaWiki + Skin core output: the outer page structure and content wrappers into which parsed content gets inserted Backend core + Skin This is the “wiki looks like a wiki” layer; Lua/templates can't normally replace this outer chrome.
Lua (#invoke) Emits arbitrary HTML/wikitext at parser-time, which become part of the delivered HTML inside content wrappers Scribunto Distinct authoring surface; an "upper layer" that still ships as part of the server HTML response.
CSS (skin + site + template-scoped) Layout/visibility/space reservation via stylesheets linked from the HTML (ResourceLoader); may include site CSS (e.g., Common.css) and other modules ResourceLoader styles + Common.css ResourceLoader explicitly treats stylesheets as a first-class, HTML-linked resource; this is where you must reserve space and define “anchors” before any JS runs.
ResourceLoader startup runtime The only script linked directly from the HTML; it bootstraps mw.loader and then batches/requests the rest of the JS modules (including gadgets and site scripts) ResourceLoader startup module Key reality check: anything not in startup (i.e., basically all custom JS) cannot influence first paint and will necessarily be “later”.
Gadget JS (ResourceLoader modules) Optional enhancement modules registered by Extension:Gadgets; can declare dependencies and can be configured for earlier loading when needed Extension:Gadgets (ResourceLoader) Gadgets are explicitly ResourceLoader modules (and can declare dependencies).
Site JS (Common.js, Skin.js) Site-wide and skin-specific scripts bundled by SiteModule (includes MediaWiki:Common.js) ResourceLoader SiteModule Common.js is delivered via ResourceLoader SiteModule.
Embedded content / third-party widgets External iframes or async components arriving late External HTML/JS Treat as content, not structure; reserve dimensions up front (width/height/aspect ratio/min-height) to avoid CLS.

What CLS Looks Like (Problem Statement)

Client-side widgets that build their interface after the initial HTML is painted can cause Cumulative Layout Shift (CLS), a visible jump when the widget appears and pushes existing content down. Search engines like Google treat CLS as a negative Core Web Vitals signal, affecting the ranking of the website.

Generic example (client-rendered widget):

  • Empty container renders (0px height)
  • JavaScript executes later
  • UI is injected dynamically
  • Browser recalculates layout → CLS occurs

ICANNWiki case study: Page Creator widget

  • Empty #hero-page-creator container renders (0px height)
  • JavaScript executes ~100–500ms later
  • Form HTML is injected dynamically
  • Browser recalculates layout → CLS occurs

Root Cause Analysis

Why Server-Rendered Structure Avoids CLS

CLS is avoided when the browser receives a complete structural skeleton in the initial HTML, and CSS hides or reserves space before first paint. JavaScript should only change visibility or populate content inside a pre-sized container.

Reliable sequence:

  1. HTML arrives: Server renders the element structure
  2. CSS applies immediately: Element is hidden or space is reserved before first paint
  3. JavaScript modifies state: Only toggles visibility or fills the container

When Client-Rendered Structure Causes CLS

If the HTML structure is created after first paint, the browser has to reflow the page when the widget is injected. That reflow is the layout shift users see.

ICANNWiki case study: The CollapseAdvancedSearch.js gadget avoids CLS because the HTML structure is server-rendered and hidden before paint, while the initial implementation was built with everything client-side, causing the shift.

MediaWiki HTML Sanitization Constraint

MediaWiki templates escape form elements for security, so raw <input>, <select>, and <button> cannot be server-rendered in templates. This forces a different approach: render safe containers server-side and let JavaScript populate them later.

Solution Pattern: Space Reservation + Deterministic Layout

When you cannot server-render form elements, use this pattern:

  1. Server-side: Render container divs only (MediaWiki-safe)
  2. CSS: Reserve space with min-height to prevent layout shift
  3. CSS Grid: Use a deterministic layout that does not reflow with late content
  4. JavaScript: Populate the pre-existing container

Implementation Architecture (Pattern + Case Study)

1. Server-Side Container Template

Create an isolated template containing only structural containers (divs only). This avoids sanitization and ensures a stable layout anchor exists from the first paint.

ICANNWiki case study: TemplateHeroPageCreatorForm.html

<!-- <%-- [PAGE_INFO]
PageTitle=#Template:HeroPageCreatorForm#
[END_PAGE_INFO] --%> -->
<div id="hero-page-creator" class="icannwiki-input-container">
<div class="icannwiki-search-unifier">
<div class="mw-inputbox-element">
<!-- JS will populate form elements here -->
</div>
</div>
</div>

Include in main template (case study):

<!-- In TemplateHero.html -->
{{Template:HeroPageCreatorForm}}

2. CSS: Space Reservation + Grid Layout

Reserve enough space for the final UI, and define a deterministic grid layout so positions are fixed from first paint.

ICANNWiki case study: CSS/MainPage.css

/* Reserve space to prevent CLS */
.client-js #hero-page-creator .mw-inputbox-element {
    min-height: 85px; /* Reserve space for 2-row desktop layout */
}

/* Desktop layout: CSS Grid for deterministic positioning */
#hero-page-creator .mw-inputbox-element {
    display: grid;
    grid-template-columns: 1fr auto;  /* Column 1: flexible, Column 2: button width */
    grid-template-rows: auto auto;    /* 2 rows */
    gap: 10px;
    justify-items: stretch;
}

/* Page name: spans both columns, row 1 */
#hero-page-creator #hero-page-name {
    grid-column: 1 / -1;
    grid-row: 1;
}

/* Template dropdown: column 1, row 2 */
#hero-page-creator #hero-template-type {
    grid-column: 1;
    grid-row: 2;
    min-width: 200px;
}

/* Button: column 2, row 2 (LOCKED next to dropdown) */
#hero-page-creator #hero-create-btn {
    grid-column: 2;
    grid-row: 2;
}

Mobile adjustments (case study):

/* Mobile: Reserve space for 3-row stacked layout */
@media screen and (max-width: 41rem) {
    .client-js #hero-page-creator .mw-inputbox-element {
        min-height: 135px; /* Taller for 3 rows */
    }
    
    /* Change to single column, 3 rows */
    #hero-page-creator .mw-inputbox-element {
        grid-template-columns: 1fr;
        grid-template-rows: auto auto auto;
        gap: 8px;
    }
    
    /* Stack elements vertically */
    #hero-page-creator #hero-page-name { grid-column: 1; grid-row: 1; }
    #hero-page-creator #hero-template-type { grid-column: 1; grid-row: 2; }
    #hero-page-creator #hero-create-btn { grid-column: 1; grid-row: 3; }
    
    /* Full width on mobile */
    #hero-page-creator #hero-page-name,
    #hero-page-creator #hero-template-type,
    #hero-page-creator #hero-create-btn {
        width: 100%;
    }
}

3. JavaScript: Populate Pre-Existing Container

JavaScript should only fill the existing container; it must not create the container itself.

ICANNWiki case study: JS/Gadgets/PageCreator.js

mw.loader.using(['mediawiki.api', 'mediawiki.util'], function() {
    $(function() {
        // Find pre-existing container
        if ($('#hero-page-creator').length === 0) return;

        console.log('Initializing page creator widget');

        // Build form HTML (target the inner container)
        var formHtml =
            '<input type="text" id="hero-page-name" class="searchboxInput" placeholder="Page name">' +
            '<select id="hero-template-type" class="searchboxInput" disabled>' +
                '<option value="">Loading...</option>' +
            '</select>' +
            '<button id="hero-create-btn" class="mw-ui-button mw-ui-progressive" disabled>Create</button>';
        
        // Populate the pre-existing container
        $('#hero-page-creator .mw-inputbox-element').html(formHtml);

        // Fetch and populate templates
        fetchTemplateList(
            function(availableTemplates) {
                var $select = $('#hero-template-type');
                $select.empty().append('<option value="">Select a subject (template)</option>');
                availableTemplates.forEach(function(template) {
                    $select.append('<option value="' + template + '">' + template + '</option>');
                });
                $select.prop('disabled', false);
            },
            function() {
                $('#hero-template-type').empty().append('<option value="">Error</option>');
            }
        );

        // Event handlers...
    });
});

CSS Grid vs Flexbox for CLS-Sensitive Layouts

Flexbox can reflow when content or fonts load, which introduces layout shifts. CSS Grid places elements explicitly, so late content changes do not move them.

Comparison

Aspect Flexbox CSS Grid
Layout basis Content width Explicit cells
Recalculation Yes (on content change) No (fixed positions)
Wrapping Dynamic (flex-wrap) None (explicit rows/columns)
CLS risk High Zero
Use case Dynamic content flow Fixed layouts

Rule of thumb: Use Grid for predictable layouts, Flexbox for content-driven flow.

ICANNWiki case study: Flexbox failure Late content population and web font loading caused flex-wrap to reflow the button to a new row, creating a second visible shift. CSS Grid eliminated this by locking positions.

Timing Diagram (Illustrative)

These timings are illustrative to show the sequence; actual times vary by device and network.

Before (With CLS)

0ms:    Empty container renders (0px height)
100ms:  JS starts executing, container still empty
500ms:  JS injects form → Browser reflows → 85px vertical shift
700ms:  Fonts load → Button jumps to new row → horizontal shift
        ❌ DOUBLE LAYOUT SHIFT

After (Zero CLS)

0ms:    Container renders with min-height: 85px (space reserved)
        Grid layout defined (positions locked)
100ms:  JS starts executing, space already reserved
500ms:  JS populates form within reserved space → no reflow
700ms:  Fonts load → Grid ignores, positions stay locked
        ✅ ZERO LAYOUT SHIFT

Replication Guide for Other Widgets

Step-by-Step Process

1. Measure Final Dimensions

Open browser DevTools, build the widget, measure:

  • Desktop height (for min-height)
  • Mobile height (for media query min-height)
  • Layout pattern (rows/columns for Grid)

2. Create Template File

Pattern: Templates/Template[WidgetName]Form.html

<!-- <%-- [PAGE_INFO]
PageTitle=#Template:[WidgetName]Form#
[END_PAGE_INFO] --%> -->
<div id="widget-id" class="widget-container">
<div class="widget-inner">
<div class="widget-content">
<!-- JS will populate here -->
</div>
</div>
</div>

Rules:

  • Only
    elements (avoid MediaWiki sanitization)
  • Use final CSS classes
  • Add comment indicating JS population

3. Include Template in Page

<!-- In main page template -->
{{Template:[WidgetName]Form}}

4. Add CSS: Space Reservation + Grid

/* Reserve space */
.client-js #widget-id .widget-content {
    min-height: [measured-height]px;
}

/* Define Grid layout */
#widget-id .widget-content {
    display: grid;
    grid-template-columns: [your-columns];
    grid-template-rows: [your-rows];
    gap: [your-gap];
}

/* Position each element explicitly */
#widget-id #element-1 {
    grid-column: [column];
    grid-row: [row];
}

/* Mobile adjustments */
@media screen and (max-width: 41rem) {
    .client-js #widget-id .widget-content {
        min-height: [mobile-height]px;
    }
    
    #widget-id .widget-content {
        grid-template-columns: 1fr;
        grid-template-rows: [mobile-rows];
    }
}

5. Update JavaScript

$(function() {
    // Find pre-existing container
    var $container = $('#widget-id .widget-content');
    if ($container.length === 0) return;

    // Build HTML
    var html = '<element1>...</element1><element2>...</element2>';
    
    // Populate pre-existing container
    $container.html(html);

    // Continue with dynamic population...
});

CSS Grid Layout Patterns

Pattern A: Two-Row Form (Desktop), Three-Row (Mobile)

/* Desktop: 2 rows, 2 columns */
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;

/* Element 1 spans both columns */
#element-1 { grid-column: 1 / -1; grid-row: 1; }

/* Element 2 and 3 share row 2 */
#element-2 { grid-column: 1; grid-row: 2; }
#element-3 { grid-column: 2; grid-row: 2; }

/* Mobile: stack all */
@media (max-width: 41rem) {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto auto;
    
    #element-1 { grid-row: 1; }
    #element-2 { grid-row: 2; }
    #element-3 { grid-row: 3; }
}

Pattern B: Equal-Width Columns

grid-template-columns: repeat(3, 1fr);
gap: 10px;

#element-1 { grid-column: 1; }
#element-2 { grid-column: 2; }
#element-3 { grid-column: 3; }

Pattern C: Sidebar + Main Content

grid-template-columns: 250px 1fr;
gap: 20px;

#sidebar { grid-column: 1; }
#main { grid-column: 2; }

Platform Considerations (MediaWiki)

CSS Class Conventions

MediaWiki automatically adds to <html>:

  • .client-js: JavaScript enabled
  • .client-nojs: JavaScript disabled

Always scope CSS with .client-js to avoid affecting no-JS state:

/* Correct */
.client-js #widget .content { min-height: 85px; }

/* Wrong - affects no-JS users */
#widget .content { min-height: 85px; }

Why min-height Is Safe for Responsive Design

Question: Does min-height break mobile responsiveness?

Answer: No, when used correctly:

  • min-height sets a floor, not a ceiling
  • Content can grow beyond min-height naturally
  • Flexbox/Grid wrapping still works
  • Use different min-height values per viewport
/* Desktop */
.client-js .widget { min-height: 85px; }

/* Mobile */
@media (max-width: 41rem) {
    .client-js .widget { min-height: 135px; }
}

What Was Tried (Case Study)

❌ Failed Attempt 1: Server-Render Form Elements

Approach: Put actual <input>, <select>, <button> in template Result: MediaWiki escaped them as literal text Why it failed: Template security sanitization Learning: Cannot server-render form elements in MediaWiki templates

❌ Failed Attempt 2: Skeleton/Real Form Swap

Approach: Server-render disabled form, JS builds real form, swap with opacity Result: Still required form elements in template (see Failed Attempt 1) Why it failed: Couldn't get past MediaWiki sanitization Learning: Patterns requiring server-rendered form elements won't work

❌ Failed Attempt 3: Flexbox with flex-wrap

Approach: Use display: flex; flex-wrap: wrap with min-height Result: Button jumped to new row during render (late-render CLS) Why it failed: Flexbox recalculates wrapping when fonts load or content populates Learning: Flexbox is too dynamic for CLS-sensitive layouts

✅ Final Success: Container + min-height + CSS Grid

Approach:

  • Server: Render divs only
  • CSS: Reserve space with min-height, lock positions with Grid
  • JS: Populate pre-existing container

Result: Zero CLS

Why it worked:

  • No MediaWiki sanitization issues (only divs)
  • Space reserved before JavaScript
  • Grid positions are deterministic
  • No dynamic recalculation

Performance Metrics (Case Study)

Before Implementation

  • CLS Score: 0.15–0.25 (Poor)
  • Layout Shifts: 2–3 visible shifts
  • First Contentful Paint: Unchanged
  • Time to Interactive: Unchanged

After Implementation

  • CLS Score: 0.00 (Good)
  • Layout Shifts: 0 visible shifts
  • First Contentful Paint: Unchanged
  • Time to Interactive: Unchanged
  • Extra HTML: ~200 bytes (negligible)
  • Extra CSS: ~30 lines

Trade-off: Minimal size increase for significantly better UX and Core Web Vitals score.

Key Takeaways

  1. Render a server-side container whenever possible; avoid creating structure after first paint
  2. Reserve space with min-height for any client-rendered UI
  3. Prefer CSS Grid for predictable, CLS-free layouts
  4. Use media queries to tune min-height for different viewports
  5. Test under throttled network to surface CLS early
  6. Measure final dimensions before choosing min-height

References

Semantic properties for "Documentation/CLS"