Scroll-animacje i „grab-to-scroll” bez bólu: jak uporządkowaliśmy interakcje w Elementorze i poprawiliśmy Core Web Vitals

Scroll-animacje i „grab-to-scroll” bez bólu: jak uporządkowaliśmy interakcje w Elementorze i poprawiliśmy Core Web Vitals

Kiedy marketing prosi o „ładne animacje na scrollu” i „karuzelę z przeciąganiem jak w aplikacji”, a Ty masz to zrobić na stronie zbudowanej w Elementorze, to zwykle zaczyna się niewinnie. Jedna klasa CSS tu, mały skrypt tam. A potem pojawiają się szczegóły: klik w kartę ma działać zawsze, przeciąganie ma być płynne na mobile, a animacje nie mogą zjadać wydajności ani psuć dostępności.

U nas (projekt WordPress/Elementor w Corecorp) problem nie był w samych wymaganiach, tylko w interakcjach między nimi. Mieliśmy dwa niezależne kawałki logiki: animacje odpalane przy wejściu w viewport oraz karuzelę z przewijaniem po osi X „na grab”. Każdy z osobna działał. Razem – pojawiały się konflikty: klik blokowany przez drag, drag „przypadkiem” uruchamiał link, a na części urządzeń UX był losowy.

Ten post opisuje, jak to uporządkowaliśmy inżyniersko: z jasnym podziałem odpowiedzialności, ograniczeniem kosztów runtime, poprawą DX i z myśleniem o Core Web Vitals (INP), dostępności i utrzymaniu w dłuższym horyzoncie.

Tło i problem

Na stronie mieliśmy sekcje z komponentami budowanymi w Elementorze. Dwa elementy były szczególnie „interakcyjne”:

  1. Bloki animowane przy wejściu w viewport – klasyczny efekt: element jest niewidoczny/odsunięty, a po wejściu na ekran dostaje klasę is-inview i CSS robi resztę. To ma działać szybko, raz, bez nasłuchiwania scrolla.
  2. Karuzela kart (np. opinie/quote cards) – przewijana poziomo, z:
    • przewijaniem myszą/trackpadem (native scroll),
    • przeciąganiem myszą i dotykiem (grab-to-scroll),
    • możliwością kliknięcia karty (czasem cały card to link).

Elementor dodaje tu swoje warstwy DOM i style, a do tego często dochodzą biblioteki, które nie znają Twoich wyjątków. Z perspektywy UX najgorsze są „losowe” zachowania: raz klik działa, raz nie; raz przeciąganie działa, raz zaznacza tekst.

To jest dokładnie ten typ problemu, który nie rozwiązuje się kolejnym stopPropagation() w ciemno – tylko porządnym modelem interakcji.

Wymagania / ograniczenia

Zebraliśmy je wprost, bo inaczej kończy się „przeciąganiem wyjątków” w nieskończoność:

Wymagania funkcjonalne

  • Animacje mają odpalać raz po wejściu w viewport.
  • Karuzela ma dawać:
    • natywne przewijanie (scrollbar/trackpad),
    • drag po osi X na desktop i mobile,
    • klikalne karty (linki, przyciski wewnątrz).

Wymagania jakościowe

  • Brak nasłuchiwania scrolla w pętli (wydajność).
  • Minimalny JS runtime: bez ciężkich bibliotek, bez layout thrashingu.
  • Zgodność z preferencją prefers-reduced-motion.
  • Dostępność: karuzela ma być używalna też bez myszy (co najmniej focus + klawiatura + natywny scroll).

Ograniczenia środowiska

  • WordPress + Elementor: markup i klasy potrafią się zmieniać, a komponenty bywają re-renderowane.
  • CSS jest współdzielony, więc „globalne hacki” są ryzykowne.
  • Musimy utrzymać kod jako mały „widget” wpięty w stronę, a nie aplikację SPA.

Co nie działało wcześniej

Najczęstszy anti-pattern w tego typu wdrożeniu to „dwa skrypty, każdy ciągnie w swoją stronę”.

1) Drag blokował klik (albo klik odpalał się po dragu)

Implementacja grab-to-scroll często wygląda tak:

  • pointerdown: zaczynamy drag,
  • pointermove: ustawiamy scrollLeft,
  • pointerup: kończymy.

Problem: przeglądarka i tak może wygenerować click, jeśli uzna, że była interakcja „kliknięcia”. A użytkownik potrafi:

  • minimalnie poruszyć kursorem i kliknąć – i to dalej jest klik,
  • przeciągnąć i puścić nad linkiem – i przypadkowo odpalić nawigację.

Bez progu ruchu i bez świadomej blokady kliknięcia UX robi się loterią.

2) Konflikty z dotykiem i przewijaniem strony

Na mobile dochodzi jeszcze konflikt: czy gest ma przewijać stronę (Y), czy karuzelę (X)? Jeśli nie ustawisz touch-action sensownie, przeglądarka będzie próbowała zgadywać, a Ty będziesz walczyć z pasywnymi listenerami i preventDefault().

3) Zaznaczanie tekstu, „drag image ghost”, przypadkowe przeciąganie elementów

Domyślnie przeciąganie po tekście/linkach może:

  • zaznaczać tekst,
  • generować „ghost image” przy drag’n’drop,
  • łapać focus w środku karty i gubić intencję użytkownika.

4) Animacje: działały, ale bez kontroli kosztu

IntersectionObserver jest dobry, ale łatwo go popsuć:

  • obserwować za dużo elementów zbyt agresywnie,
  • odpalać w kółko (bez unobserve),
  • mieszać klasy odpowiedzialne za animacje z klasami odpowiedzialnymi za layout.

5) DX: brak jednego miejsca, gdzie widać „kontrakt”

Gdy logika jest porozrzucana, każdy kolejny fix jest ryzykiem regresji:

  • ktoś zmieni selector,
  • ktoś dopisze e.preventDefault() w złym miejscu,
  • i nagle klik przestaje działać w 3 sekcjach, bo share’ują klasę.

Nowe podejście / architektura

Zamiast „naprawiać bugi”, zrobiliśmy małą architekturę interakcji. Klucz: podział odpowiedzialności i jasny kontrakt stanów.

Zasady, które przyjęliśmy:

  • Animacje i karuzele to osobne moduły inicjalizacji: initInView() i initDragScroll().
  • Karuzela ma jawny stan: idlepresseddragging.
  • Klik blokujemy tylko wtedy, gdy wykryliśmy realny drag (próg px).
  • Nie walczymy z natywnym scrollowaniem – używamy go jako baseline, a drag traktujemy jako ulepszenie.
  • CSS robi jak najwięcej: overflow-x, scroll-snap (opcjonalnie), touch-action, cursor, user-select.

To podejście ma dwa plusy biznesowe:

  • mniej regresji (czyli mniej czasu na QA i mniej „hotfixów na produkcji”),
  • lepsze metryki UX (INP i subiektywna płynność), co przekłada się na konwersję w landingach.

Jak to działa

Animacje „in-view” oparte o IntersectionObserver

W animacjach trzymaliśmy się prostego kontraktu:

  • element ma klasę bazową (np. e-cc-animation-container),
  • po wejściu w viewport dostaje is-inview,
  • observer odłącza element, żeby animacja odpaliła tylko raz.

Pseudokod (w praktyce prawie identyczny JS):

export function initInViewAnimations({
  selector = '.e-cc-animation-container',
  threshold = 0.2,
} = {}) {
  const els = document.querySelectorAll(selector);
  if (!els.length) return;

  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;

      entry.target.classList.add('is-inview');
      io.unobserve(entry.target); // odpal raz, nie marnuj CPU
    }
  }, { threshold });

  els.forEach(el => io.observe(el));
}

Dlaczego to działa dobrze:

  • brak scroll listenerów,
  • brak ciągłych obliczeń,
  • prosta semantyka w CSS,
  • łatwo dodać prefers-reduced-motion po stronie stylów.

W CSS trzymamy animację po stronie transform/opacity (bez layoutu), np. z przejściem tylko po dodaniu is-inview.

Grab-to-scroll: Pointer Events, próg ruchu i blokada kliknięcia

Najważniejsza część: odróżnić klik od dragu. To brzmi banalnie, ale bez tego nie da się mieć jednocześnie:

  • przewijania przeciąganiem,
  • klikalnych kart,
  • stabilnego UX.

Model stanu:

  • pressed: pointer w dół, jeszcze nie wiemy czy to klik czy drag,
  • dragging: użytkownik wykonał ruch > thresholdPx,
  • po pointerup: kończymy; jeśli było dragging, blokujemy klik.

Implementacja (JS, z komentarzami i praktycznymi detalami):

export function initDragScrollCarousels({
  selector = '.corecorp-quote-cards-carousel',
  thresholdPx = 6,
  sensitivity = 1,
} = {}) {
  const carousels = document.querySelectorAll(selector);
  if (!carousels.length) return;

  carousels.forEach((el) => {
    let isPressed = false;
    let isDragging = false;
    let startX = 0;
    let startScrollLeft = 0;

    // Blokada kliknięcia działa najlepiej w capture phase
    const blockClickIfDragged = (e) => {
      if (!isDragging) return;
      e.preventDefault();
      e.stopPropagation();
    };

    el.addEventListener('click', blockClickIfDragged, true);

    el.addEventListener('pointerdown', (e) => {
      // tylko primary button / dotyk
      if (e.pointerType === 'mouse' && e.button !== 0) return;

      isPressed = true;
      isDragging = false;

      startX = e.clientX;
      startScrollLeft = el.scrollLeft;

      el.classList.add('is-pressed');
      el.setPointerCapture(e.pointerId); // stabilność, nawet gdy pointer wyjedzie poza el
    });

    el.addEventListener('pointermove', (e) => {
      if (!isPressed) return;

      const dx = e.clientX - startX;

      // Dopiero po przekroczeniu progu uznajemy to za drag
      if (!isDragging && Math.abs(dx) >= thresholdPx) {
        isDragging = true;
        el.classList.add('is-dragging');
      }

      if (!isDragging) return;

      // scroll w przeciwną stronę niż ruch ręki zwykle daje „naturalny” feel
      el.scrollLeft = startScrollLeft - dx * sensitivity;
    });

    const end = (e) => {
      if (!isPressed) return;

      isPressed = false;
      el.classList.remove('is-pressed');
      el.classList.remove('is-dragging');

      // Oddajemy capture – porządki
      try { el.releasePointerCapture(e.pointerId); } catch {}
      // Uwaga: isDragging zostawiamy true jeszcze na moment,
      // bo click może przyjść po pointerup.
      // Resetujemy w microtasku, żeby click handler w capture zdążył zadziałać.
      queueMicrotask(() => { isDragging = false; });
    };

    el.addEventListener('pointerup', end);
    el.addEventListener('pointercancel', end);
    el.addEventListener('pointerleave', (e) => {
      // jeśli pointer jest captured, leave może się pojawić – nie zawsze kończymy
      // ale w praktyce bezpieczniej domknąć stan, jeśli nie mamy capture
      if (!isPressed) return;
      // opcjonalnie: end(e)
    });
  });
}

Dlaczego to jest stabilne:

  • setPointerCapture() eliminuje „rwanie” dragu.
  • Próg thresholdPx rozwiązuje 80% przypadków z przypadkowym ruchem przy klikaniu.
  • Klik blokujemy w capture phase, więc linki i przyciski wewnątrz karty nie „uciekną” przed nami.
  • Reset isDragging w queueMicrotask() jest prostym sposobem, żeby click zdążył zostać zatrzymany.

Uwaga praktyczna: jeśli w karuzeli masz elementy typu <a> lub <button>, to blokada kliknięcia musi działać konsekwentnie. Capture + preventDefault() to najpewniejsza opcja.

CSS i ergonomia: touch-action, scroll-snap, focus i a11y

JS bez CSS w tej funkcji to proszenie się o kłopoty. Minimalny zestaw, który robi różnicę:

.corecorp-quote-cards-carousel {
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;

  /* pozwól przewijać stronę w pionie, a poziom obsłużymy my */
  touch-action: pan-y;

  cursor: grab;
  scroll-behavior: smooth; /* opcjonalnie, np. pod przyciski next/prev */
}

.corecorp-quote-cards-carousel.is-dragging {
  cursor: grabbing;
  user-select: none;
}

.corecorp-quote-cards-carousel > * {
  /* opcjonalnie: snap dla kart */
  scroll-snap-align: start;
}
.corecorp-quote-cards-carousel {
  scroll-snap-type: x mandatory; /* opcjonalnie */
}

Touch-action: kluczowe dla mobile. pan-y zostawia pionowy scroll strony w spokoju, a poziomy gest traktujemy jako interakcję z karuzelą.

Dostępność (a11y) – minimum, które warto dowieźć:

  • karuzela powinna być focusowalna: tabindex="0" na kontenerze (jeśli nie ma naturalnie focusowalnych elementów),
  • natywny scroll działa też z klawiatury (np. strzałkami w niektórych przeglądarkach, PageDown/PageUp zależnie od platformy),
  • nie wyłączamy scrollbara „na siłę” (albo robimy to ostrożnie, bo scrollbara to też affordance).

Preferencja reduced motion:

  • jeśli animacje są czysto dekoracyjne, wyłącz je w @media (prefers-reduced-motion: reduce) – w praktyce: bez przejść i bez transform.

Wyniki / metryki / lekcje

Nie zawsze masz luksus twardych danych z RUM, ale nawet bez tego można uczciwie opisać efekty i podać szacunki.

Co zauważyliśmy po wdrożeniu

  • Mniej zgłoszeń „klik nie działa”: bo klik jest blokowany tylko wtedy, gdy faktycznie był drag.
  • Płynniejszy drag na mobile: touch-action + pointer capture robią dużą różnicę.
  • Prostszy debugging: kiedy coś się dzieje, patrzysz na klasy is-pressed / is-dragging i od razu wiesz, w jakim stanie jest komponent.
  • Mniejszy koszt runtime: IntersectionObserver zamiast scroll listenerów.

Szacunkowe metryki (realistyczne, ale zależne od strony)

Poniżej to typowe efekty, które widzieliśmy w podobnych projektach; traktuj jako szacunek, nie obietnicę:

  • spadek liczby handlerów i pracy na scrollu → poprawa responsywności w interakcji; INP może poprawić się rzędu 20–80 ms na słabszych urządzeniach (szacunek),
  • redukcja „przypadkowych nawigacji” z karuzeli → mniejszy bounce z landingów (szacunek, zależy od ruchu i treści),
  • mniejsza złożoność kodu: jeden moduł do animacji, jeden do karuzeli → mniej regresji w kolejnych iteracjach.

Lekcje, które warto zapamiętać

  • Klik i drag to dwa różne zamiary użytkownika – musisz mieć próg i stan, inaczej będziesz gasić pożary.
  • Capture phase to nie „hack”, tylko narzędzie, które w UI komponentach bywa konieczne.
  • CSS jest częścią logiki interakcji (touch-action, user-select, cursor). Bez tego JS będzie walczył z platformą.
  • IntersectionObserver to standardowy wybór dla animacji „in-view” – i nadal łatwo go zepsuć, jeśli nie odłączysz obserwacji.

Lista praktycznych wniosków (do skopiowania do checklisty PR):

  • Ustal próg drag: 4–8 px (desktop) i przetestuj na touch.
  • Zablokuj click tylko po wykryciu dragu (capture + preventDefault).
  • Użyj setPointerCapture dla stabilności.
  • Dodaj touch-action: pan-y na kontenerze karuzeli.
  • Animacje odpalaj raz i unobserve elementy.
  • Zadbaj o prefers-reduced-motion.

Co dalej / roadmapa

Po „ustabilizowaniu” podstaw mamy kilka sensownych kroków, które zwykle dają zwrot z inwestycji:

  1. Obsługa dynamicznego DOM (Elementor / lazy load)
    Jeśli sekcje są dogrywane lub re-renderowane, inicjalizacja „na starcie” nie wystarczy. Rozwiązanie: lekki MutationObserver (z limitami), który inicjuje nowe elementy tylko w obrębie konkretnego wrappera.
  2. Testy interakcji (Cypress/Playwright)
    Najdroższe regresje to te, których nie widać w unit testach: drag vs click, focus, mobile viewport. Prosty test E2E, który:
    • przeciąga karuzelę i sprawdza scrollLeft,
    • klika kartę bez dragu i sprawdza nawigację,
    • wykonuje minimalny ruch i upewnia się, że klik nadal działa.
  3. Lepsza klawiatura i przyciski nawigacji
    Dla a11y i UX można dodać przyciski prev/next (z aria-controls) i przewijanie o szerokość karty. To też pomaga użytkownikom bez gestów.
  4. Telemetry (jeśli macie RUM)
    Warto mierzyć:
    • liczbę klików w karuzeli vs liczbę dragów,
    • eventy „blocked click due to drag” (agregowane, anonimowe),
    • INP na stronach z ciężkimi sekcjami.

Wnioski

Interakcje UI na stronach marketingowych często wyglądają na „drobiazgi”, dopóki nie zaczną psuć konwersji i generować zgłoszeń. Dobra wiadomość: to da się zrobić prosto i stabilnie, jeśli potraktujesz problem jak inżynier, a nie jak „kolejny hack w JS”.

Najważniejsze elementy układanki to:

  • animacje „in-view” oparte o IntersectionObserver (bez scroll listenerów),
  • grab-to-scroll z modelem stanu i progiem ruchu,
  • blokada kliknięcia tylko po dragu (capture),
  • CSS: touch-action, user-select, sensowne overflow.

Jeśli chcesz to wdrożyć u siebie, zacznij od jednej karuzeli i jednego zestawu animacji, dodaj progi, klasy stanu i testy E2E. A jeśli utkniesz na konflikcie z Elementorem/markupiem – daj znać, pokaż fragment DOM i event flow, zwykle da się to rozwiązać bez przepisywania pół strony.

Na ile ten artykuł był dla Ciebie pomocny?

Powiązane wpisy