Kamil Owczarek
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ą:

  1. Ciemne tło
  2. Warstwę gradientu z siatką kropek
  3. Warstwę gradientu poświaty
  4. Warstwę treści
  5. 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(&quot;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&quot;);"
/>

Efekt wizualny identyczny. SVG to kafelek 24×24 z jednym kółkiem — dokładnie to, co produkował gradient. Tylko że teraz przeglądarka:

  1. Parsuje SVG raz
  2. Rasteryzuje go do bitmapy
  3. 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: transform na 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:

WzorzecKosztPoprawka
radial-gradient z małym background-sizeWysokiSVG w data URI
Wiele nałożonych warstw gradientowychWysokiUsunąć albo wyrenderować wcześniej
gradient-to-t/b na wielu powtarzalnych elementachŚredniUsunąć albo dać jednolite kolory
hover:shadow-* na siatkach kartŚredniTylko statyczny cień
Kolorowe/przezroczyste cienieŚredniStandardowe cienie albo żadne
position: fixed z przezroczystościąŚredniJednolity kolor tła
backdrop-blurBardzo wysokiUnikać 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.