- Opublikowano
Jak CSS-owy radial-gradient zabił mi płynność scrollowania (i jednolinijkowa poprawka na SVG)
- Autorzy
Objaw, którego nikt nie potrafił wyjaśnić
Przebudowywałem sekcję hero w Digital Nomad, narzędziu do porównywania miast dla osób pracujących zdalnie. Nowy projekt zakładał ciemne tło z subtelną siatką kropek — częsty motyw na nowoczesnych landing page'ach.
Wszystko wyglądało ostro. A potem zacząłem scrollować.
Cała strona się zacinała. Karty pojawiały się z widocznym opóźnieniem. Przeglądarka wyraźnie się męczyła, a przecież nie było tam żadnych animacji, żadnego backdrop-blur, żadnego ciężkiego JavaScriptu. Chrome DevTools pokazywał skoki czasu paintu na każdej klatce scrollowania.
Godzinami goniłem złe tropy: lazy loadowane obrazki, liczbę komponentów Vue, listenery scrolla, narzut Nuxt DevTools. Żaden z nich nie był winowajcą.
Problemem były dwie deklaracje CSS.
Kosztowny wzorzec
Tak wyglądała sekcja hero:
<!-- Dot grid: radial-gradient repeated across the entire viewport -->
<div
class="absolute inset-0 opacity-[0.07]"
style="background-image: radial-gradient(circle, rgba(255,255,255,0.5) 1px, transparent 1px);
background-size: 24px 24px;"
/>
<!-- Glow: two large elliptical gradients -->
<div
class="absolute inset-0"
style="background: radial-gradient(ellipse 60% 50% at 20% 20%, rgba(42,157,143,0.15) 0%, transparent 70%),
radial-gradient(ellipse 50% 60% at 80% 80%, rgba(6,182,212,0.1) 0%, transparent 70%);"
/>
Wizualnie? Pięknie. Delikatna siatka kropek z miękką turkusową poświatą. Coś, co w Figmie wygląda na zupełnie niewinne.
Pod maską? Katastrofa.
Dlaczego to jest tak drogie
Matematyka siatki kropek
radial-gradient z background-size: 24px 24px tworzy powtarzalny wzór. Na typowym viewporcie 1920×816 (hero na 85vh) przeglądarka liczy:
- 80 kolumn × 34 rzędy = około 2720 kółek gradientowych
- Każde kółko wymaga policzenia gradientu radialnego od środka do krawędzi
- To liczenie dzieje się przy każdej klatce kompozycji podczas scrollowania
W przeciwieństwie do zrasteryzowanego obrazka, który GPU kafelkuje bez wysiłku, gradienty CSS są przeliczane przez silnik renderujący. Kiedy element bierze udział w scrollowaniu (a bierze, bo siedzi w przewijanej stronie), przeglądarka odmalowuje go na każdej klatce.
Gradienty poświaty
Dwa wielkie eliptyczne gradienty pokrywające cały viewport dokładają jeszcze więcej roboty:
- Dwie warstwy gradientu składane razem
- Każda pokrywa około 70% viewportu wraz z przejściami kolorów
- W połączeniu z leżącą wyżej warstwą siatki kropek przeglądarka składa 3 pełnoekranowe warstwy gradientowe na klatkę
Stos kompozycji
Dołóż do tego nagłówek z position: fixed (który wymusza własną warstwę kompozycji) i przeglądarka miesza ze sobą:
- Ciemne tło
- Warstwę gradientu z siatką kropek
- Warstwę gradientu poświaty
- Warstwę treści
- Warstwę przyklejonego nagłówka
To 5 warstw kompozycji przeliczanych na każdej klatce scrolla. Nic dziwnego, że muliło.
Rozwiązanie: inline SVG jako data URI
Siatka kropek wcale nie musi być gradientem. To statyczny, powtarzalny element wizualny — dokładnie to, do czego służą kafelkowane obrazki. Nie chciałem jednak dokładać zewnętrznego pliku graficznego dla jednej ozdobnej kropki.
Rozwiązanie: inline SVG zakodowany jako data URI.
<!-- Before: expensive radial-gradient (recalculated per frame) -->
<div
class="absolute inset-0 opacity-[0.07]"
style="background-image: radial-gradient(circle, rgba(255,255,255,0.5) 1px, transparent 1px);
background-size: 24px 24px;"
/>
<!-- After: SVG data URI (rasterized once, GPU-tiled) -->
<div
class="absolute inset-0 opacity-[0.07]"
style="background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='1' fill='rgba(255,255,255,0.5)'/%3E%3C/svg%3E");"
/>
Efekt wizualny identyczny. SVG to kafelek 24×24 z jednym kółkiem — dokładnie to, co produkował gradient. Tylko że teraz przeglądarka:
- Parsuje SVG raz
- Rasteryzuje go do bitmapy
- Kafelkuje go, korzystając z powtarzania tekstury na GPU — czyli praktycznie za darmo
Zero liczenia gradientów na klatkę. Zero repaintów przy scrollowaniu.
A co z poświatą?
Gradienty poświaty usunąłem w całości. Były na tyle subtelne, że ich brak jest ledwo zauważalny, a koszt wydajnościowy miały spory. Jeśli potrzebujesz podobnego efektu, rozważ:
- Wcześniej wyrenderowaną nakładkę PNG/WebP (ładowana raz, składana przez GPU)
- Jeden prostszy gradient zamiast dwóch nałożonych na siebie
will-change: transformna elemencie, żeby wypchnąć go na osobną warstwę GPU (używaj oszczędnie)
Inni zabójcy płynnego scrollowania, których znalazłem
Przy okazji debugowania namierzyłem kilka innych wzorców, które nakładają się na problem:
Nakładki gradientowe na obrazkach kart
<!-- 20 cards × 1 gradient each = 20 gradient layers during scroll -->
<div class="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/50 to-transparent" />
Każda karta miała nakładkę gradientową dla czytelności tekstu. Przy 20 widocznych kartach to 20 dodatkowych warstw gradientowych składanych podczas scrollowania. Usunąłem je, a plakietce z pogodą dałem po prostu nieco ciemniejsze tło.
Zmiana cienia na hover
<!-- Triggers repaint on every card during mouse movement -->
<div class="shadow-sm hover:shadow-md">
Zmiana box-shadow przy najechaniu myszą wymusza repaint elementu. Przy 20 kartach w viewporcie przesuwanie myszy po siatce powoduje nieustanne repainty. Wyrzuciłem zmianę cienia na hover — karty wyglądają dobrze ze statycznym shadow-sm.
Kolorowe cienie
<!-- Colored shadows require extra compositing -->
<div class="shadow-lg shadow-accent-500/25">
shadow-accent-500/25 tworzy kolorowy, półprzezroczysty cień. To zmusza przeglądarkę do składania koloru cienia z tłem przy każdym paincie. Na przycisku CTA zrezygnowałem z cienia w ogóle — siedzi na ciemnym tle, gdzie cieni i tak nie widać.
Przyklejony nagłówek z przezroczystością
<!-- opacity forces compositing on every scroll frame -->
<header class="fixed bg-[#060E1B]/90">
Element fixed z przezroczystością (/90 to 90% krycia) tworzy warstwę kompozycji, którą przeglądarka musi mieszać z treścią pod spodem na każdej klatce. Przejście na jednolity kolor (bg-[#060E1B]) całkowicie eliminuje składanie kanału alfa.
Checklista
Jeśli twoja strona muli przy scrollowaniu, poszukaj tych wzorców:
| Wzorzec | Koszt | Poprawka |
|---|---|---|
radial-gradient z małym background-size | Wysoki | SVG w data URI |
| Wiele nałożonych warstw gradientowych | Wysoki | Usunąć albo wyrenderować wcześniej |
gradient-to-t/b na wielu powtarzalnych elementach | Średni | Usunąć albo dać jednolite kolory |
hover:shadow-* na siatkach kart | Średni | Tylko statyczny cień |
| Kolorowe/przezroczyste cienie | Średni | Standardowe cienie albo żadne |
position: fixed z przezroczystością | Średni | Jednolity kolor tła |
backdrop-blur | Bardzo wysoki | Unikać całkowicie |
Efekt
Przed poprawką scrollowanie wyraźnie się szarpało — karty jakby „wskakiwały” na ekran, a strona sprawiała wrażenie ociężałej. Po zamianie gradientu na SVG w data URI i usunięciu nałożonych warstw gradientowych scrollowanie jest płynne, w 60 klatkach na sekundę.
Różnica wizualna? Żadna. Siatka kropek wygląda identycznie. Poświaty nie ma, ale nikt tego nie zauważył.
Czasem najskuteczniejszą optymalizacją wydajności jest skasowanie jednej właściwości CSS.