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-creatorcontainer 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:
- HTML arrives: Server renders the element structure
- CSS applies immediately: Element is hidden or space is reserved before first paint
- 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:
- Server-side: Render container divs only (MediaWiki-safe)
- CSS: Reserve space with
min-heightto prevent layout shift - CSS Grid: Use a deterministic layout that does not reflow with late content
- 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-heightsets a floor, not a ceiling- Content can grow beyond
min-heightnaturally - Flexbox/Grid wrapping still works
- Use different
min-heightvalues 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
- Render a server-side container whenever possible; avoid creating structure after first paint
- Reserve space with
min-heightfor any client-rendered UI - Prefer CSS Grid for predictable, CLS-free layouts
- Use media queries to tune
min-heightfor different viewports - Test under throttled network to surface CLS early
- Measure final dimensions before choosing
min-height
References
ICANNWiki resources: Special Pages | Content Guide | Documentation | Development || Maintenance: Articles needing attention | Candidates for deletion || Projects: Internet & Digital Governance Library