How a WordPress event carousel shortcode fixes LCP delays and a HiDPI image bug through conditional Swiper loading, eager-loading rules, and dynamic loop detection.
ACF, Elementor, Websites

Fixing an LCP Bottleneck in a WordPress Carousel Shortcode

How a WordPress event carousel shortcode fixes LCP delays and a HiDPI image bug through conditional Swiper loading, eager-loading rules, and dynamic loop detection.
WordPress Carousel Shortcode

Conditional asset loading, a lazy-loading conflict that broke retina images, and a shortcode built to drop an event carousel into any page without asking anything of the editor.

Carousels have a bad reputation among performance-minded developers, and most of the time it's earned. The usual failure mode is simple: the slider library loads on every page, whether or not that page actually contains a slider, and the images inside it fight the browser's own lazy-loading heuristics instead of cooperating with them. This is the story of untangling both problems on a real event carousel — built for any site that needs to show a rotating set of items with a date, a location, and a thumbnail: concerts, tournaments, classes, festivals, whatever the content type happens to be.

The two problems, before any code

Problem one was Largest Contentful Paint. The carousel's first image — frequently the actual LCP element on pages where it sat near the top — was being lazy-loaded like every other image on the page, which delays it until the browser decides it's needed. For an above-the-fold slider, that's exactly backwards.

Problem two was subtler and took longer to trace: on high-density displays, the browser was sometimes choosing a 768px image variant from the responsive srcset instead of the full-resolution one, even when the container was rendering larger than that. The visual result was a slightly soft, upscaled-looking thumbnail on retina screens — not broken, just wrong in a way that's hard to point at directly.

Fixing the HiDPI image bug

The fix here is blunt, and I want to be upfront that it's blunt on purpose. Rather than debugging the exact sizes attribute math that was misleading the browser, the plugin strips srcset and sizes entirely from any image carrying the carousel's image class, falling back to the single resolution requested at generation time.

add_filter('wp_get_attachment_image_attributes', function($attr, $attachment) {
    if (isset($attr['class']) && strpos($attr['class'], 'cs-slider-image') !== false) {
        unset($attr['srcset']);
        unset($attr['sizes']);
    }
    return $attr;
}, 10, 2);

Losing responsive variants trades a small amount of bandwidth efficiency for a fixed, predictable output — one image size, chosen deliberately, no browser guesswork involved. For a fixed-aspect-ratio carousel tile, where the display size never actually varies across breakpoints, that trade costs almost nothing and removes an entire category of rendering bug.

Loading the slider library only where it's needed

The library backing the carousel — Swiper, in this implementation — has no business loading on a page that doesn't contain the shortcode. The plugin handles two separate contexts differently. Inside the Elementor editor and preview mode, it loads unconditionally, because the editor needs to render the carousel live without knowing in advance whether the shortcode is present on the canvas yet.

function concerti_slider_enqueue_swiper_editor() {
    if (!defined('ELEMENTOR_VERSION')) return;

    $is_editor  = isset(\Elementor\Plugin::$instance->editor)  && \Elementor\Plugin::$instance->editor->is_edit_mode();
    $is_preview = isset(\Elementor\Plugin::$instance->preview) && \Elementor\Plugin::$instance->preview->is_preview_mode();

    if ($is_editor || $is_preview) {
        wp_enqueue_style('swiper-css', '.../swiper-bundle.min.css', array(), '11.0.0');
        wp_enqueue_script('swiper-js', '.../swiper-bundle.min.js', array(), '11.0.0', true);
    }
}
add_action('elementor/editor/before_enqueue_scripts', 'concerti_slider_enqueue_swiper_editor');
add_action('elementor/preview/enqueue_styles', 'concerti_slider_enqueue_swiper_editor');

On the actual front end, the enqueue happens inside the shortcode function itself, triggered only when the shortcode actually runs. This works specifically because Elementor processes shortcodes before wp_head fires — call wp_enqueue_style or wp_enqueue_script late, during shortcode execution, and it still lands in the page's head output correctly. A page without the shortcode never touches Swiper at all. No conditional page-template logic required, no settings screen asking an editor to remember to "enable the slider script" — the shortcode's own presence is the only signal needed.

Avoiding a duplicate library load

Elementor ships its own bundled Swiper instance for native Slides and Carousel widgets, registered under handles like e-swiper or, in some versions, just swiper. Running a page that has both a native Elementor carousel widget and this custom shortcode risked loading Swiper twice — wasted bytes at best, JS conflicts at worst.

add_action('wp_enqueue_scripts', function() {
    if (wp_style_is('swiper-css', 'enqueued')) {
        wp_dequeue_style('e-swiper');
        wp_dequeue_style('swiper');
    }
}, 999);

Priority 999 isn't an arbitrary high number chosen for safety margin — it needed to run after Elementor's own late re-enqueues, which happen closer to page render than most plugin developers expect. One caveat worth stating plainly: this dequeue logic assumes the custom carousel's Swiper build is a superset of what Elementor's native widgets need. On a page actually using Elementor's own Slides widget alongside this shortcode, that assumption should be tested rather than trusted.

The eager-loading exception for the first slide

Inside the loop that builds each slide, exactly one image gets different loading treatment — the first one.

echo wp_get_attachment_image(
    get_post_thumbnail_id($post_id),
    array(300, 300),
    false,
    array(
        'loading'       => $is_first ? 'eager' : 'lazy',
        'fetchpriority' => $is_first ? 'high'  : 'auto',
        'class'         => 'cs-slider-image no-lazyload skip-lazy',
        'alt'           => esc_attr($title),
    )
);

The no-lazyload and skip-lazy classes aren't decorative — they're there to opt the image out of third-party lazy-loading plugins and hosting-level optimization layers that ignore the native loading attribute and apply their own JS-based lazy loading regardless. Without those classes, an eager-loaded first image can still end up lazy-loaded a second time by something further down the stack, silently undoing the fix.

Deciding when the carousel loops

Swiper's loop mode duplicates slides internally to create the illusion of infinite scrolling, and it looks genuinely broken with too few slides relative to the visible count — repeats become obvious, transitions stutter. The shortcode calculates whether looping makes sense based on the actual slide count against how many are visible at once.

var enableLoop = (totalSlides >= slidesDesktop * 2);

Below that threshold, loop mode simply doesn't activate, and the carousel behaves as a bounded, non-repeating strip instead. Nobody configuring the shortcode has to think about this — it's derived automatically from the query results, not a parameter anyone sets by hand.

Where the JavaScript actually executes

Initialization happens in wp_footer, at priority 99, rather than through wp_add_inline_script attached to the Swiper handle.

add_action('wp_footer', function() use ($inline_js) {
    echo '';
}, 99);

I switched to this approach after inline-script attachment produced inconsistent initialization order on pages with the carousel positioned unusually in the DOM. Footer output at a fixed late priority guarantees the Swiper library itself is already parsed and available by the time this code runs — a small thing, but the kind of small thing that turns into an intermittent, hard-to-reproduce bug report if it's wrong.

The shortcode parameters

The shortcode accepts a set of attributes covering content selection and layout. categories takes a comma-separated list of category slugs to pull from — mixing content types freely, since nothing in the query logic assumes a single content taxonomy. posts caps how many items the query returns, clamped between 1 and 50 regardless of what's passed. slides_desktop, slides_tablet, and slides_mobile control how many items are visible at each breakpoint, each independently clamped to sane maximums so a typo can't render an unusable carousel. exclude_current drops the currently viewed post from its own related-items carousel, which matters on single event pages showing "other upcoming events" — without it, an item would list itself. exclude_ids takes a comma-separated list of post IDs to hard-exclude regardless of category match, useful for pinning specific items out of a promotional carousel without touching their categories. show_date toggles the date line under each title, and hide_past filters out anything whose end date has already passed, checked against two possible ACF field configurations depending on which date schema the post was built with.

Using it on a page

A typical call looks like this, mixing several content categories, capping at a dozen items, showing dates, and excluding a handful of specific posts that shouldn't appear in this particular carousel instance:

[event_carousel categories="articles,dance,concerts,opera,news,classical-festivals,series,concert-season" posts="12" show_date="yes" exclude_ids="47264,48718,49129,49573,50006,50019"]

A second, much more common pattern shows up on homepages and sidebar widgets: a compact, single-category carousel with dates turned off entirely, because the context is editorial rather than event-driven — a "latest articles" strip rather than a listing of things happening on specific dates.

[event_carousel show_date="no" posts="6" categories="articles" slides_desktop="3" slides_tablet="2" slides_mobile="2"]

This second call is worth pausing on, because it exercises a different part of the shortcode's logic than the first one does. With show_date="no", the entire date-resolution branch — the ACF field lookups, the two possible field-set configurations, the date-range formatting — never runs at all, since $mostra_data gates that whole block. For a pure content carousel with no date semantics, that's not just cosmetic; it skips real query and formatting work on every page load. Six posts and three visible slides on desktop also means enableLoop evaluates to false — 6 >= 3 * 2 is true, actually, so this particular combination sits right at the loop threshold. Drop posts to five with the same slides_desktop="3" and looping would disable itself automatically, which is exactly the kind of edge case the threshold check exists to catch without anyone noticing it happened.

Both calls use the same shortcode, the same PHP, the same enqueue logic — only the attributes change. That's the point of keeping category names, date visibility, and breakpoint counts as parameters rather than hardcoding any of them: an editorial "latest posts" strip and a full multi-category event listing are the same component wearing different configuration, not two different pieces of code to maintain.

Scope and licensing

This implementation assumes ACF is active and that event date data lives in one of two known field configurations, selected via the date_field_set parameter — a site with a differently structured date schema would need a third branch added to that logic, not a rewrite. Whether the eager-loading exception for slide one is still the right call on a page where the carousel sits well below the fold is a fair question, and one this shortcode doesn't try to answer on its own — it always treats the first slide as eager, regardless of its position on the page.

The shortcode is released under the GPL-2.0+ license. It requires WordPress 5.8 or above, PHP 7.4 or later, Advanced Custom Fields, and Swiper 11 loaded via the enqueue logic described above.

Need help with this solution or looking for custom development?

Visit my Stay In Touch page to connect and discuss your project. Discover a range of web development services and specialized WordPress solutions tailored to your needs. Let's work together to enhance your digital presence.