Elementor Loop Carousel – horizontaler Mausrad-Scroll auf Desktop

Status: Aktiv im Einsatz

Typ: JavaScript + CSS

Zweck: Ein Elementor Loop Carousel wird auf Desktop mit dem normalen Mausrad horizontal durchlaufen. Während dieser Bewegung bleibt die Sektion optisch an ihrer Position. Sobald die letzte sinnvolle Kachelgruppe sichtbar ist, übernimmt wieder der normale vertikale Seiten-Scroll.

Ziel

Der Besucher scrollt wie gewohnt vertikal durch die Seite. Erreicht er die Carousel-Sektion, wird der Wheel-Scroll vorübergehend in eine horizontale Bewegung des Loop Carousels übersetzt. Die Seite bewegt sich in dieser Phase nicht weiter nach unten.

Am Ende soll nicht erst die letzte einzelne Kachel bis zum Rand bewegt werden. Die horizontale Bewegung endet bereits dann, wenn die letzten drei Kacheln gemeinsam sichtbar sind und rechts keine weiteren Inhalte mehr folgen. Der nächste Scrollimpuls bewegt die Seite wieder vertikal weiter.

Auf Tablet und Smartphone greift die Sonderlogik nicht. Dort bleibt das normale Elementor-/Swiper-Verhalten erhalten.

Notwendige CSS-Klassen in Elementor

Folgende Klassen werden in Elementor vergeben:

  • i2-horizontal-scroll – äußerer Bereich der Scroll-Sektion
  • i2-horizontal-sticky – innerer Container der Sektion
  • i2-horizontal-carousel – Elementor Loop Carousel

Das Script verwendet innerhalb des Elementor Loop Carousels zusätzlich die von Swiper erzeugten Elemente .swiper-wrapper und .swiper-slide.

Wichtig: kein echtes CSS-Sticky

Obwohl der innere Container die Klasse i2-horizontal-sticky trägt, verwendet diese Lösung bewusst kein position: sticky.

Der Sticky-Effekt entsteht durch die Wheel-Logik: Solange das Carousel horizontal bewegt werden kann, verhindert JavaScript mit event.preventDefault() den normalen vertikalen Scroll. Dadurch bleibt die Seite optisch an derselben Position, während sich nur die Kacheln horizontal bewegen.

Die Klasse i2-horizontal-sticky daher nicht auf position: sticky umstellen. Ein echtes CSS-Sticky verändert das Scrollverhalten dieser Lösung und kann dazu führen, dass die Kacheln beim Übergang zum nächsten Seitenbereich zu weit aus dem sichtbaren Bereich laufen.

Warum keine künstliche Scroll-Höhe?

Eine klassische horizontale Scroll-Lösung kann mit einem hohen äußeren Container und einem darin liegenden Sticky-Element umgesetzt werden. Die Höhe des äußeren Containers bildet dabei den horizontalen Scrollweg vertikal ab.

Bei längeren Carousels entsteht dadurch allerdings zusätzlicher vertikaler Platz. Nach dem sichtbaren Carousel kann deshalb eine große leere Fläche stehen, bevor der nächste Seitenbereich erreicht wird.

Diese Lösung verzichtet vollständig auf einen künstlichen Scrollbereich. Der äußere Container behält seine normale Inhaltshöhe.

JavaScript

<script>
document.addEventListener('DOMContentLoaded', function () {

    const section = document.querySelector('.i2-horizontal-scroll');
    const carousel = section?.querySelector('.i2-horizontal-carousel');
    const wrapper = carousel?.querySelector('.swiper-wrapper');

    if (!section || !carousel || !wrapper) {
        return;
    }

    const breakpoint = 1024;

    let position = 0;
    let maxMove = 0;


    /*
     * Horizontale Strecke ermitteln
     */
    function measure() {

        if (window.innerWidth <= breakpoint) {
            wrapper.style.removeProperty('transform');
            return;
        }

        const slides = wrapper.querySelectorAll('.swiper-slide');

        if (!slides.length) {
            return;
        }

        /*
         * Drei Kacheln sollen am Ende sichtbar bleiben.
         *
         * Bei 6 Kacheln ist das also Kachel 4.
         * Sobald sie an der Startposition von Kachel 1 steht,
         * ist die horizontale Bewegung beendet.
         */
        const visibleAtEnd = 3;

        const firstFinalSlide = slides[
            Math.max(0, slides.length - visibleAtEnd)
        ];

        maxMove = Math.max(
            0,
            firstFinalSlide.offsetLeft - slides[0].offsetLeft
        );

        position = Math.min(position, maxMove);

        wrapper.style.transform =
            `translate3d(${-position}px, 0, 0)`;
    }


    /*
     * Der Bereich übernimmt das Mausrad nur,
     * wenn er an der gewünschten Position steht.
     */
    function sectionIsActive() {

        const rect = section.getBoundingClientRect();
        const viewportHeight = window.innerHeight;

        return (
            rect.top <= viewportHeight * 0.35 &&
            rect.bottom >= viewportHeight * 0.45
        );
    }


    function handleWheel(event) {

        if (window.innerWidth <= breakpoint) {
            return;
        }

        if (!sectionIsActive()) {
            return;
        }

        const delta =
            Math.abs(event.deltaY) >= Math.abs(event.deltaX)
                ? event.deltaY
                : event.deltaX;

        if (Math.abs(delta) < 1) {
            return;
        }


        /*
         * =================================================
         * NACH UNTEN / NACH RECHTS
         * =================================================
         */
        if (delta > 0) {

            const nextPosition = position + Math.abs(delta);

            /*
             * Dieser Scrollimpuls erreicht die letzte
             * vorgesehene Kachelgruppe.
             *
             * Endposition exakt setzen, den vertikalen
             * Scroll aber nicht mehr blockieren.
             */
            if (nextPosition >= maxMove) {

                position = maxMove;

                wrapper.style.transform =
                    `translate3d(${-maxMove}px, 0, 0)`;

                return;
            }

            /*
             * Noch nicht am Ende:
             * Scroll weiterhin horizontal verwenden.
             */
            event.preventDefault();

            position = nextPosition;

            wrapper.style.transform =
                `translate3d(${-position}px, 0, 0)`;

            return;
        }


        /*
         * =================================================
         * NACH OBEN / NACH LINKS
         * =================================================
         */
        if (delta < 0) {

            const amount = Math.abs(delta);
            const remaining = position;


            /*
             * Horizontal bereits am Anfang:
             * normalen Browser-Scroll freigeben.
             */
            if (remaining <= 0) {

                position = 0;

                wrapper.style.transform =
                    'translate3d(0, 0, 0)';

                return;
            }


            /*
             * Dieser Wheel-Impuls reicht über
             * den Anfang hinaus.
             */
            if (amount >= remaining) {

                event.preventDefault();

                const overflow = amount - remaining;

                position = 0;

                wrapper.style.transform =
                    'translate3d(0, 0, 0)';


                /*
                 * Rest des Impulses sofort vertikal
                 * nach oben weitergeben.
                 */
                if (overflow > 0) {
                    window.scrollBy({
                        top: -overflow,
                        left: 0,
                        behavior: 'auto'
                    });
                }

                return;
            }


            /*
             * Normaler horizontaler Schritt zurück.
             */
            event.preventDefault();

            position -= amount;

            wrapper.style.transform =
                `translate3d(${-position}px, 0, 0)`;
        }
    }


    /*
     * Erste Messung nach Aufbau des Layouts.
     *
     * Achtung:
     * Das allein reicht bei Elementor/Swiper nicht zuverlässig.
     * Swiper kann seine endgültigen Slide-Breiten erst später setzen.
     */
    requestAnimationFrame(function () {
        requestAnimationFrame(measure);
    });


    /*
     * WICHTIG: Race Condition mit Elementor/Swiper abfangen.
     *
     * Beim ersten measure() kann der Swiper noch nicht vollständig
     * initialisiert sein. Dadurch können falsche Slide-Breiten und
     * eine viel zu große horizontale Strecke berechnet werden.
     *
     * Der ResizeObserver misst erneut, sobald Swiper die tatsächlichen
     * Abmessungen des Wrappers bzw. Carousels gesetzt hat.
     */
    const resizeObserver = new ResizeObserver(function () {
        measure();
    });

    resizeObserver.observe(wrapper);
    resizeObserver.observe(carousel);


    window.addEventListener(
        'wheel',
        handleWheel,
        {
            passive: false
        }
    );


    /*
     * Bei Änderungen der Fenstergröße neu messen.
     */
    let resizeTimer;

    window.addEventListener('resize', function () {

        clearTimeout(resizeTimer);

        resizeTimer = setTimeout(
            measure,
            150
        );

    });

});
</script>

CSS

@media (min-width: 1025px) {

    .i2-horizontal-scroll {
        position: relative;
        height: auto !important;
        min-height: 0 !important;
        max-height: none !important;
    }

    .i2-horizontal-sticky {
        position: relative !important;
        top: auto !important;
    }

    .i2-horizontal-carousel {
        overflow: hidden;
    }

    .i2-horizontal-carousel .swiper-wrapper {
        transition: transform 0.15s ease-out;
        will-change: transform;
    }
}

Das visuelle Ende des Carousels

Ein wichtiger Punkt dieser Lösung ist die Definition des horizontalen Endes. Es wird nicht anhand der gesamten Breite des Swiper-Wrappers berechnet.

Eine Berechnung wie diese ist für diesen Anwendungsfall ungeeignet:

maxMove = wrapper.scrollWidth - carousel.clientWidth;

Sie würde den Wrapper so lange verschieben, bis sein technisches Ende erreicht ist. Die letzten Kacheln würden dadurch weiter nach links wandern, obwohl rechts längst keine neuen Inhalte mehr erscheinen.

Stattdessen wird die erste Kachel der letzten sichtbaren Gruppe ermittelt:

const visibleAtEnd = 3;

const firstFinalSlide = slides[
    Math.max(0, slides.length - visibleAtEnd)
];

maxMove = Math.max(
    0,
    firstFinalSlide.offsetLeft - slides[0].offsetLeft
);

Bei sechs Kacheln und drei gleichzeitig sichtbaren Kacheln endet die Bewegung deshalb bei folgender Ansicht:

[ Kachel 4 ] [ Kachel 5 ] [ Kachel 6 ]

Sobald diese Gruppe erreicht ist, gibt das Script den vertikalen Scroll wieder frei.

Wichtig: Elementor-/Swiper-Race-Condition

Elementor initialisiert das Loop Carousel über Swiper. Beim Ereignis DOMContentLoaded kann der Swiper zwar bereits im DOM vorhanden sein, seine endgültigen Abmessungen müssen zu diesem Zeitpunkt aber noch nicht feststehen.

Dadurch kann dieselbe Seite nach einem Reload scheinbar zufällig unterschiedlich reagieren: Bei einem Aufruf funktioniert der horizontale Scroll korrekt, beim nächsten läuft das Carousel viel zu weit.

Im konkreten Fehlerfall wurden folgende Werte gemessen:

Falsche frühe Messung:

Slides:             6
Wrapper-Breite:     7130 px
Carousel-Breite:    1180 px
maxMove:            3570 px


Korrekte Messung nach Swiper-Initialisierung:

Slides:             6
Wrapper-Breite:     2380 px
Carousel-Breite:    1180 px
maxMove:            1190 px

Die Anzahl der Slides war in beiden Fällen korrekt. Der Unterschied entstand ausschließlich dadurch, dass Swiper bei der ersten Messung seine endgültigen Slide-Breiten noch nicht gesetzt hatte.

Zwei requestAnimationFrame()-Aufrufe reichen deshalb nicht als zuverlässige Absicherung. Sie verschieben die erste Messung zwar um zwei Browser-Frames, garantieren aber nicht, dass Elementor und Swiper zu diesem Zeitpunkt vollständig fertig sind.

ResizeObserver als Absicherung

Der ResizeObserver beobachtet den Swiper-Wrapper und das Carousel:

const resizeObserver = new ResizeObserver(function () {
    measure();
});

resizeObserver.observe(wrapper);
resizeObserver.observe(carousel);

Ändert Swiper nachträglich die tatsächlichen Abmessungen, wird measure() automatisch erneut ausgeführt. Dadurch ersetzt die korrekte Messung einen eventuell zu früh berechneten Wert.

Den ResizeObserver nicht entfernen. Ohne ihn kann das Verhalten nach einem Reload wieder vom Zeitpunkt der Swiper-Initialisierung abhängen.

Anzahl der sichtbaren Kacheln ändern

Die Anzahl der Kacheln, die in der letzten Ansicht gemeinsam sichtbar bleiben sollen, wird hier festgelegt:

const visibleAtEnd = 3;

Bei einem anderen Carousel-Layout muss dieser Wert an die gewünschte Desktop-Darstellung angepasst werden. Bei vier gleichzeitig sichtbaren Kacheln wäre beispielsweise visibleAtEnd = 4 die passende Ausgangsbasis.

Breakpoint

Die horizontale Wheel-Steuerung läuft ausschließlich auf Desktop:

const breakpoint = 1024;

Bis einschließlich 1024 Pixel greift das Script nicht in das Scrollverhalten ein. Der manuell gesetzte Transform wird entfernt und Elementor bzw. Swiper übernimmt wieder die normale Carousel-Steuerung.

Elementor-/Swiper-Einstellungen

Endloses Looping darf für diese Variante nicht aktiv sein. Das Script geht von einer echten ersten und letzten Kachel aus. Bei von Swiper duplizierten Slides wäre die Berechnung der letzten sichtbaren Gruppe nicht mehr eindeutig.

Das Loop Carousel sollte auf Desktop außerdem so konfiguriert sein, dass die Anzahl der gleichzeitig sichtbaren Kacheln zum Wert visibleAtEnd passt.

Warum passive: false?

Der Wheel-Listener wird mit passive: false registriert, weil das Script während der horizontalen Phase event.preventDefault() verwendet. Nur so kann der normale vertikale Browser-Scroll für diese Scrollimpulse tatsächlich angehalten werden.

window.addEventListener(
    'wheel',
    handleWheel,
    {
        passive: false
    }
);

Scrollen nach oben

Die Logik funktioniert auch in Gegenrichtung. Beim Scrollen nach oben werden die Kacheln zunächst wieder horizontal zurückbewegt. Ist der Anfang erreicht, übernimmt der normale vertikale Browser-Scroll.

Reicht ein Wheel-Impuls über den horizontalen Anfang hinaus, wird der verbleibende Anteil mit window.scrollBy() vertikal weitergegeben. Dadurch entsteht beim Verlassen des Carousels nach oben kein unnötiger zusätzlicher Scrollschritt.

Fehlersuche

Wenn die Funktion nicht reagiert, zuerst prüfen, ob die verwendeten Elemente im DOM gefunden werden:

document.querySelector('.i2-horizontal-scroll')
document.querySelector('.i2-horizontal-carousel')
document.querySelector('.i2-horizontal-carousel .swiper-wrapper')

Alle drei Abfragen müssen ein DOM-Element liefern.

Bei einem scheinbar zufälligen Verhalten nach Reloads können die aktuell gemessenen Werte vorübergehend in einer globalen Debug-Variable gespeichert werden:

window.i2HorizontalDebug = {
    maxMove: maxMove,
    slides: slides.length,
    firstSlide: slides[0].offsetLeft,
    firstFinalSlide: firstFinalSlide.offsetLeft,
    wrapperWidth: wrapper.scrollWidth,
    carouselWidth: carousel.clientWidth
};

Nach dem Laden können die Werte gezielt in der Browser-Konsole abgefragt werden:

window.i2HorizontalDebug

Dadurch muss nicht zwischen zahlreichen Elementor- oder Browser-Meldungen nach einer bestimmten Console-Ausgabe gesucht werden.

Achtung: Bei einer abgedockten Browser-Konsole darauf achten, dass sie tatsächlich zum Tab der zu prüfenden Website gehört.

Wichtiger Hinweis für spätere Änderungen

Die funktionierende Kombination nicht auf echtes CSS-Sticky umbauen und die Wheel-Logik nicht unnötig vereinfachen. Die normale Höhe der Sektion, position: relative, das gezielte preventDefault(), die Berechnung über die letzte sichtbare Kachelgruppe und der ResizeObserver gehören gemeinsam zur Lösung.

Insbesondere darf das horizontale Ende nicht wieder über die vollständige Breite des Swiper-Wrappers berechnet werden. Entscheidend ist das visuelle Ende: Sobald die letzten Kacheln vollständig sichtbar sind und keine weiteren mehr folgen, übernimmt wieder der normale vertikale Scroll.

Wenn das Verhalten nach einem Reload scheinbar zufällig wechselt, zuerst die gemessenen Slide- und Wrapper-Breiten prüfen. Unterschiedliche Werte bei identischem Layout sind ein deutlicher Hinweis darauf, dass vor Abschluss der Swiper-Initialisierung gemessen wurde.