Advanced Lazy Loading on heavily animated WordPress websites

Splash screen of the lazy load article

There’s been plenty written about basic lazy loading, so we won’t go too deep into it. We’ll touch on the key points of the main techniques, their pros and cons, and then look at more advanced techniques that can save your animated website.

Posted On

May 22, 2026

Time To Read

11 mins

loading="lazy"

It’s universal and accessible – just add it to everything outside the hero section. The browser decides on its own when and how to load, based on speed and current load.

Pros

  • Native
  • Many CMS platforms add it automatically; there are plenty of plugins that handle this
  • Minimal setup required

Cons

  • The browser might easily go overboard and start loading images 2–3 screens early, or start way too late, leaving the user staring at blinking empty boxes. Chromium starts loading at roughly ~1250px on a fast connection and ~2500px on a slow one (historically, these thresholds were as high as 3000px and 8000px).
  • It doesn’t handle horizontally scrollable sections well – it often only starts showing content after the element enters the viewport. Same issue with position: sticky.
  • Completely uncontrollable.

Data attributes and placeholders

Here things become much more controllable since JavaScript does everything on our terms.

We can split this into two main approaches:

  1. With a placeholder.
  2. Without a placeholder.

Both work almost identically – we swap src, srcset, and sizes for data-src, data-srcset, and data-sizes. Once the element is 1-2 screens away, we swap them back to their original values, and there’s your lazily loaded image. In the placeholder version, src isn’t left empty – it gets a 1-2 KB SVG or JPEG image.

Which one to pick – that’s up to you. Google claims that Googlebot renders JavaScript and sees the final DOM state, including the swapped-in src. Some in the community play it safe: if rendering doesn’t finish in time, the bot might index the placeholder. In our own projects, we leave src empty – and we haven’t seen any indexing issues.

Lazy loading for video

Video is a different story. preload is a browser hint, and it’s important to understand which direction it can be ignored in.

preload=”none” is respected by browsers.

Modern browsers won’t start loading video data if preload="none" is set – there are no confirmed cases of the opposite in current Chrome, Firefox, or Safari. A browser may load less than requested (on mobile, preload="auto" often falls back to metadata to save bandwidth).

But there’s a catch. Background videos on animated sites almost always have autoplay muted playsinline. The autoplay attribute completely overrides preload – the browser must load data to start playback. In that case, preload="none" is useless.

The solution:

remove src from <source> and put it in data-src. Without src, the browser has nothing to load – regardless of whether autoplay is present. When the IntersectionObserver fires, we swap in the real path and call video.load(). By the time the video enters the viewport, it’s already buffering.

Lazy for heavily animated sites

On sites with lots of animations – horizontal sections, custom scroll, 3D transforms – native loading="lazy" just doesn’t cut it. Watching a flashy site where loading images pop in mid-transition is a pretty dubious experience.

IntersectionObserver is what’s left. But applying the same rootMargin to every element runs into problems:

Horizontal sections. If they’re built with overflow-x: hidden + transform: translateX() (like in GSAP ScrollTrigger), elements physically sit outside the viewport horizontally. IO with root=null can see their bounding rect and correctly detect intersection, but rootMargin only sets vertical offsets. A horizontal margin can be set, but it’s fixed and doesn’t account for 5+ screens of content within a section.

Container scroll. When body has overflow: hidden and a nested div is what scrolls (common for preventing address bar jumps on mobile browsers that break 3D animations), IO with root=null stops receiving events: from the viewport’s perspective, nothing is moving. You need to explicitly set the scroll container as the Observer’s root – and standard plugins don’t do this.

Our solution

A dual-mode system: self and parent.

Self – the Observer watches the image itself. Good for regular vertical sections. Works like classic lazy loading but with a controllable threshold and custom root support.

Parent – the Observer watches the parent container (by default .e-parent – Elementor’s container class). When the container enters the zone, all nested lazy media load at once. This solves the horizontal section problem: we don’t wait for each image to scroll into view – we load everything as the section approaches.

So, we created a custom WordPress + Elementor plugin: the PHP side moves src, srcset, and sizes into data attributes. In the admin panel, you pick the mode and default thresholds; in the Elementor editor, you configure settings per widget.

Three threshold tiers: tier 1 (~100% viewport height), tier 2 (~150%), tier 3 (~200%). If a section is tall and static – use self with tier 2. If a section is horizontal or has heavy animations – use parent with tier 3, so everything loads well in advance.

Supporting stuff

Separate desktop/mobile cache. The hero section on desktop might contain images that end up below the fold on mobile. With server-side caching, you need branching: separate lazy settings for each device type.

Custom root for IO. If scrolling is handled by a nested container, you specify its selector in the settings, and the script uses that element as the Observer’s root. Without this, rootMargin won’t work.

Optimizing sizes/srcset. On mobile, we can programmatically trim unnecessary variants from srcset, reducing the chance of loading unnecessarily heavy images.

Implementation example

The server (WordPress + Elementor) prepares the markup: either native loading="lazy" (soft mode) or data-src + placeholder (hard mode). Elements get data-lazy-* attributes with tier and scope settings.

The client spins up an IntersectionObserver with the configured root and rootMargin. On intersection – it swaps in real URLs, removes native lazy, and unblocks video. The script is not loaded in the Elementor editor.

				
					public function enqueue_brs_lazy_frontend() {

	if ( BRS_Lazy_API::is_elementor_editor_render() ) {
		return;
	}

	wp_register_script(
		'brs-lazy-frontend',
		BRS_WIDGETS_PLUGIN_URL . 'assets/js/brs-lazy-frontend.js',
		[],
		BRS_WIDGETS_PLUGIN_VERSION,
		true
	);

	$config = [
		'active'   => true,
		'loadMode' => BRS_Lazy_API::is_hard_mode() ? 'hard' : 'soft',
	];

	wp_enqueue_script( 'brs-lazy-frontend' );
	wp_add_inline_script(
		'brs-lazy-frontend',
		'window.BRS_Lazy=' . wp_json_encode( $config ) . ';',
		'before'
	);
}
				
			

Image loading modes

Soft (global): the wp_get_attachment_image_attributes filter adds loading="lazy" to all images except those with fetchpriority="high", the brs-no-lazy class, or elements that already have data-src set.

				
					public static function filter_global_soft_native_lazy( $attr, $attachment, $size ) {

	if ( ( $o['images_enabled'] ?? 'no' ) !== 'yes' || self::is_hard_mode() ) {
		return $attr;
	}

	// Skip exclusions: high-priority, already set, no-lazy class, data-src present

	$attr['loading'] = 'lazy';
	unset( $attr['decoding'] );
	return $attr;
}
				
			

Hard: the real src goes into data-src, replaced by an SVG placeholder. srcset / sizes move into data attributes. Native loading is removed.

				
					if ( BRS_Lazy_API::is_hard_mode() ) {

	$ph = BRS_Lazy_API::placeholder_image_url();

	if ( ! empty( $attr['src'] ) && $attr['src'] !== $ph ) {
		$attr['data-src'] = $attr['src'];
		$attr['src']      = $ph;
	}

	if ( ! empty( $attr['srcset'] ) ) {
		$attr['data-srcset'] = $attr['srcset'];
		unset( $attr['srcset'] );
	}

	if ( ! empty( $attr['sizes'] ) ) {
		$attr['data-sizes'] = $attr['sizes'];
		unset( $attr['sizes'] );
	}

	unset( $attr['loading'] );
}
				
			

On the front end: in soft mode, we remove loading="lazy" when the threshold is crossed so loading follows our `rootMargin` instead of browser defaults. In hard mode – we move data-srcsrc.

				
					function activateHardImage(el) {
	// data-src → src, data-srcset → srcset, data-sizes → sizes
	// Handle <source> inside <picture>
}

function activateSoftImage(el) {
	if (el.getAttribute('loading') === 'lazy') {
		el.removeAttribute('loading');
	}
	stripLazyMetaAttrs(el);
}
				
			

Video

Elements with the brs-lz-video class contain a <source> with data-src instead of src. The autoplay attribute stays – but without src, there’s nothing for the browser to play. On activation: we swap in the real src and call video.load() – the video starts buffering and playing.

Exclusions and integrations

Exclusions: CSS selectors in the admin panel + the brs-no-lazy class / data-brs-no-lazy attribute.

Perfmatters and similar tools: the lazy script needs to be excluded from Delay / Defer JS, otherwise the Observer kicks in after elements have already appeared on screen.

So...

The right approach depends on the complexity of the project. For static sites, the native attribute is enough. For animated sites, you need controllable JavaScript that understands the context of each element.

Principles we’ve taken away: lazy loading should be predictable (we know the trigger threshold), contextual (self for vertical, parent for horizontal), and adaptive (different settings for different devices and sections).

P.S. If you need some assistance with this, just drop us a line.

Lead Developer at Bersus

Alex is a seasoned expert with 9 years of experience in WebGL, WordPress, VR/AR, and ecommerce, turning complex (and sometimes crazy) ideas into seamless, user-friendly solutions with passion and attention to detail.

Back
minimap_22350_05-22-2026_15-28-23.jpeg
Latest Posts
FREE
Join the Loop

Subscribe for exclusive access to my tailored knowledge base, inspiring content, the latest in tech, and practical tutorials that empower your growth.

By submitting, you agree to receive email updates. Unsubscribe anytime.