
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”:
- Bloki animowane przy wejściu w viewport – klasyczny efekt: element jest niewidoczny/odsunięty, a po wejściu na ekran dostaje klasę
is-inviewi CSS robi resztę. To ma działać szybko, raz, bez nasłuchiwania scrolla. - 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: ustawiamyscrollLeft,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()iinitDragScroll(). - Karuzela ma jawny stan:
idle→pressed→dragging. - 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
scrolllistenerów, - brak ciągłych obliczeń,
- prosta semantyka w CSS,
- łatwo dodać
prefers-reduced-motionpo 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łodragging, 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
thresholdPxrozwią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
isDraggingwqueueMicrotask()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-draggingi 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
setPointerCapturedla stabilności. - Dodaj
touch-action: pan-yna kontenerze karuzeli. - Animacje odpalaj raz i
unobserveelementy. - Zadbaj o
prefers-reduced-motion.
Co dalej / roadmapa
Po „ustabilizowaniu” podstaw mamy kilka sensownych kroków, które zwykle dają zwrot z inwestycji:
- Obsługa dynamicznego DOM (Elementor / lazy load)
Jeśli sekcje są dogrywane lub re-renderowane, inicjalizacja „na starcie” nie wystarczy. Rozwiązanie: lekkiMutationObserver(z limitami), który inicjuje nowe elementy tylko w obrębie konkretnego wrappera. - 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.
- przeciąga karuzelę i sprawdza
- Lepsza klawiatura i przyciski nawigacji
Dla a11y i UX można dodać przyciski prev/next (zaria-controls) i przewijanie o szerokość karty. To też pomaga użytkownikom bez gestów. - 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.