You're In The Right Place If…
- Lighthouse Performance diagnostics list "Image elements do not have explicit width and height" with a stack of offending <img> tags
- Your Cumulative Layout Shift (CLS) score is above 0.1 — or PageSpeed Insights shows real-user CLS failing Core Web Vitals
- Content visibly jumps down as images load — especially on slow connections or long pages with lazy-loaded images
- Users mis-click things because a button moved at the exact moment an image above it finished loading
- Your CMS module code SETS a size-attributes variable (e.g. HubSpot's sizeAttrs boilerplate) but the <img> tag never outputs it — the attributes exist in the template and still never reach the HTML
What The Attributes Actually Do (it Is Not What Most People Think)
The old-school belief is that width="558" height="393" forces the image to display at 558x393 pixels. Not anymore. In every modern browser, when your CSS also sizes the image (max-width: 100%, width: 100%, etc.), the HTML attributes are used for exactly one thing: computing the intrinsic ASPECT RATIO (558:393) so the browser can reserve a correctly-proportioned box before the file downloads. CSS still wins on final display size.
That is why the fix is safe on responsive sites: the attributes stop the layout jump, and your stylesheet keeps controlling how big the image actually renders at every breakpoint. The one companion rule your CSS needs — and most frameworks already ship it — is height: auto whenever you set a width in CSS. Without it, a CSS width plus an HTML height attribute can stretch the image out of proportion:
/* the standard companion rule — most resets/frameworks already include it: */
img {
max-width: 100%;
height: auto;
} Every Reason To Add Them (not Just Lighthouse)
- CLS / Core Web Vitals: reserved space = no layout jump = lower CLS, and CLS is a Google ranking signal measured from real Chrome users, not just lab runs
- Mis-click prevention: shifting layouts cause users to click the wrong link or button at the exact moment content moves — a real conversion killer on CTAs near images
- Lazy loading works properly: loading="lazy" images below the fold get correct scroll positions only if their space is reserved — otherwise the page length keeps changing as you scroll and anchors land in the wrong place
- Faster perceived rendering: the browser can finalize layout in one pass instead of recalculating (reflowing) after every image arrives — cheaper on low-end phones
- Stable anchors and back-button positions: jumping content breaks #section links and scroll restoration
- Screen magnifier and low-vision users: content that relocates mid-read is far more disorienting at 400% zoom
How To Add Them, Case By Case
The values should be the image FILE's intrinsic pixel dimensions — or any pair with the same aspect ratio (the ratio is what the browser uses). Where to find them: hover the image URL in DevTools (shows "intrinsic size"), or open the file directly in a tab and read the title bar, or check the CMS media library details.
Plain HTML — add both attributes, no units:
<!-- BEFORE (reserves no space, causes shift): -->
<img class="home_tabs_main_image" src="/hubfs/hr-solutions.webp"
alt="HR Software and Solutions" loading="lazy">
<!-- AFTER (browser reserves a 558:393 box immediately): -->
<img class="home_tabs_main_image" src="/hubfs/hr-solutions.webp"
width="558" height="393"
alt="HR Software and Solutions" loading="lazy">
<!-- Works identically WITH responsive srcset images —
width/height describe the ratio, sizes/srcset pick the file: -->
<img src="/hs-fs/hubfs/hr-solutions.webp?width=558&name=hr-solutions.webp"
srcset="/hs-fs/hubfs/hr-solutions.webp?width=332&name=hr-solutions.webp 332w,
/hs-fs/hubfs/hr-solutions.webp?width=558&name=hr-solutions.webp 558w"
sizes="(max-width: 991px) 100vw, 332px"
width="558" height="393"
alt="HR Software and Solutions" loading="lazy"> CMS Templates: Pull The Dimensions From The Image Field
Hard-coding numbers is fine for fixed images, but in CMS modules where editors swap images, read the dimensions from the platform. HubSpot image fields expose .width and .height; WordPress adds the attributes automatically for library images inserted the normal way (it only misses hand-written theme <img> tags); Shopify Liquid exposes image.width / image.height. The pattern everywhere is the same:
<!-- HubSpot (HubL) — dimensions travel with whatever image the editor picks: -->
<img src="{{ module.tab_image.src|escape_url }}"
width="{{ module.tab_image.width }}" height="{{ module.tab_image.height }}"
alt="{{ module.tab_image.alt|escape_attr }}" loading="lazy">
<!-- Shopify (Liquid): -->
<img src="{{ product.featured_image | image_url: width: 600 }}"
width="600" height="{{ 600 | divided_by: product.featured_image.aspect_ratio | round }}"
alt="{{ product.featured_image.alt | escape }}" loading="lazy"> The "ghost Variable" Bug: Your Module SETS The Attributes But Never OUTPUTS Them
Here is a failure mode we keep finding in real CMS themes, and it is sneaky because the code LOOKS correct. Module boilerplate (HubSpot's generated modules are a prime example) carefully builds a sizeAttrs variable with the right width/height for every size mode — and then the <img> tag below forgets to print it. The variable is set, never used: a ghost. Every image the module renders ships without dimensions, no matter how diligently editors fill in the image fields.
This is also why "just fix the images in the editor" does not work: many images never pass through the rich-text editor at all. Logo sliders, testimonial avatars, tab panels, card grids — those <img> tags live inside module templates, and if the template drops the attributes, every instance on every page inherits the bug. One template fix repairs them all; no amount of editor-side effort can.
The tell-tale pattern (HubSpot example) — sizeAttrs is built in three flavors, then the img tag only prints loadingAttr:
{# The GHOST — built with care, then never printed: #}
{% set sizeAttrs = 'width="{{ item.width|escape_attr }}" height="{{ item.height|escape_attr }}"' %}
{% if item.size_type == 'auto' %}
{% set sizeAttrs = 'width="{{ item.width|escape_attr }}" height="{{ item.height|escape_attr }}" style="max-width: 100%; height: auto;"' %}
{% endif %}
{% set loadingAttr = item.loading != 'disabled' ? 'loading="{{ item.loading|escape_attr }}"' : '' %}
<!-- BUG: sizeAttrs is missing from the tag -->
<img class="slider_item_image" src="{{ item.src|escape_url }}" alt="{{ item.alt|escape_attr }}" {{ loadingAttr }}>
<!-- FIX: print it -->
<img class="slider_item_image" src="{{ item.src|escape_url }}" alt="{{ item.alt|escape_attr }}" {{ sizeAttrs }} {{ loadingAttr }}>
{# Sweep the whole theme: search your modules for "sizeAttrs" (or width=) and check
each <img> actually outputs it — the ghost is usually copy-pasted everywhere. #} Find Every Offender In One Paste
Run this in the DevTools console on any page — it lists every image missing either attribute, with its file and intrinsic dimensions so you can copy the numbers straight in:
// List images missing width/height attributes + the values to add:
document.querySelectorAll('img').forEach(function (img) {
if (!img.getAttribute('width') || !img.getAttribute('height')) {
console.log(
(img.currentSrc || img.src).split('?')[0],
'→ add: width="' + img.naturalWidth + '" height="' + img.naturalHeight + '"'
);
}
}); Edge Cases Worth Knowing
- SVGs: give them width/height too (or ensure the SVG file has a viewBox) — an SVG without either can collapse to 0 or default to 300x150 and shift the layout like any raster image
- CSS background-images never shift (the element sizes itself), but they also never lazy-load natively — do not convert <img> to backgrounds just to dodge this audit
- The <picture> element: put width/height on the inner <img> tag; if art-directed sources have DIFFERENT aspect ratios, modern browsers accept width/height on each <source> as well
- Aspect-ratio CSS is an alternative (aspect-ratio: 558 / 393 on the img or a wrapper) — useful when HTML attributes are out of reach, but the attributes remain the standard, works-everywhere mechanism
- Wrong values are worse than none if wildly off: the browser reserves the wrong shape, then STILL shifts when the real image arrives — always use the actual file ratio
The Fix, Step By Step
- 1
Audit the page for images missing the attributes
Paste the console snippet above into DevTools on each key template (home, blog post, product page). It prints every offending image with the exact width/height values to add. Lighthouse's own audit details list them too.
- 2
Add width and height with the file's intrinsic dimensions
Two attributes, integer pixels, no units: width="558" height="393". Any pair with the correct aspect ratio works — the ratio is what reserves the space.
- 3
In CMS modules, read dimensions from the image field
HubSpot: module.field.width / .height. WordPress: insert via the media library (automatic) and fix hand-written theme tags. Shopify: image.width / image.aspect_ratio. This keeps the attributes correct when editors swap images.
- 4
Check module templates for ghost variables
Search your theme's module code for size-attribute variables (HubSpot: sizeAttrs) and verify each <img> tag actually prints them — boilerplate frequently builds the variable and then omits it from the tag. Images inside modules (sliders, cards, tabs) never pass through the rich-text editor, so this is fixable only in the template.
- 5
Confirm your CSS has the height:auto companion rule
img { max-width: 100%; height: auto; } — this lets CSS control display size while the attributes control the reserved ratio. Without height:auto, an HTML height attribute can distort a CSS-resized image.
- 6
Re-run Lighthouse and watch CLS
The audit should clear, and if images were your main shifter, CLS drops with it. For the real-user verdict, check the Core Web Vitals section of PageSpeed Insights or Search Console after ~28 days of field data.
From the trenches
How we hit this on a real production site
This came out of the same enterprise B2B audit as our responsive-images work this week. The site's tab-switcher images shipped as bare <img> tags — no width, no height — so every tab image arrived as a layout surprise, and the audit list was long.
The fix rode along with the srcset upgrade we were already doing: each image got width/height attributes describing its intrinsic ratio, pulled from the HubSpot image fields so editors cannot break it by swapping images later. The two features are perfect partners — srcset/sizes picks the right FILE for each screen, width/height reserves the right SPACE before any file arrives.
The audit cleared on the next run, and the page stopped visibly assembling itself during load. Two attributes per image is the cheapest Core Web Vitals win there is — if your Lighthouse report lists this audit with a long stack of images, the console snippet above turns it into a fifteen-minute job.
Frequently Asked Questions
Will width and height attributes break my responsive layout?
No — this is the most common fear and it has been obsolete since 2019. When CSS sizes the image (max-width: 100%; height: auto), browsers use the attributes only to compute the aspect ratio for space reservation. CSS still controls the final rendered size at every breakpoint.
Do the attribute values have to match the displayed size?
No. They should describe the image file's intrinsic dimensions — or any pair with the same aspect ratio. A 1200x800 image displayed at 300px wide is correctly served by width="1200" height="800"; the browser reserves a 3:2 box and CSS scales it.
I use srcset with different file sizes — which dimensions do I put?
Any pair matching the shared aspect ratio (typically the largest file's dimensions). All srcset variants of one image should have the same ratio, so one width/height pair describes them all. If you art-direct different CROPS per breakpoint via <picture>, put matching width/height on each <source>.
What about images injected by JavaScript, sliders, or embeds?
Same rule, set at creation: include the attributes in the template the script renders, or set img.width / img.height before appending. For third-party embeds you cannot modify, wrap them in a container with CSS aspect-ratio to reserve the space externally.
Is CSS aspect-ratio a full replacement for the attributes?
Functionally close — aspect-ratio: 558 / 393 reserves space the same way. But the HTML attributes are self-documenting, survive CSS load failures, are what Lighthouse checks for directly, and require no stylesheet coordination. Use CSS aspect-ratio when you cannot touch the HTML; use the attributes everywhere else.
Still stuck? Send us the exact error.
Paste the exact error message you're seeing and where it happens. We'll take a look — if it's quick we'll point you at the fix, and if it's deeper we'll tell you honestly what it takes.