Stylowalne ikonki SVG
To, że SVG jest lepsze od fontów z ikonami, jest już raczej dość powszechną wiedzą. Ale jeden problem związany z SVG wydaje się nas prześladować od lat i wciąż nie widać rozwiązania na horyzoncie: ikony SVG w zewnętrznym pliku są średnio używalne. Nie da się ich ot tak wczytać z zewnętrznej domeny (same origin policy, FTW!), nie da się stylować ich części, a do niedawna w ogóle nic się nie dało z nimi zrobić…
Wszystkie ikony użyte w tym artykule pochodzą z Devicon.
Problem
Klient życzy sobie ikonki FB. Znaleźliśmy zatem takową:
Wstawiamy ją na stronę jako standardowy SVG:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="32" height="32" style="background: #3d5a98; border-radius: 6.53px;">
<path fill="#fff" id="f" class="cls-2" d="M86.48,123.17V77.34h15.38l2.3-17.86H86.48V48.08c0-5.17,1.44-8.7,8.85-8.7h9.46v-16A126.56,126.56,0,0,0,91,22.7C77.38,22.7,68,31,68,46.31V59.48H52.62V77.34H68v45.83Z"></path>
</svg>
Nic prostszego. Ale klient sobie życzy, żeby po najechaniu tło robiło się różowe a literka – zielona. No ok, da się zrobić:
<style>
.icon-facebook {
background: #3d5a98;
border-radius: 6.53px;
}
.icon-facebook:hover {
background: pink;
}
.icon-facebook:hover .cls-2 {
fill: #0f0;
}
</style>
<svg xmlns="http://www.w3.org/2000/svg" class="icon-facebook" viewBox="0 0 128 128" width="32" height="32">
<path fill="#fff" id="f" class="cls-2" d="M86.48,123.17V77.34h15.38l2.3-17.86H86.48V48.08c0-5.17,1.44-8.7,8.85-8.7h9.46v-16A126.56,126.56,0,0,0,91,22.7C77.38,22.7,68,31,68,46.31V59.48H52.62V77.34H68v45.83Z"></path>
</svg>
No to ostatnie życzenie klienta: ikonka musi się znajdować w osobnym pliku, nie bezpośrednio w HTML-u. I choćbyśmy nie wiem jak błagali, tak ma być i koniec. Inaczej miliardowy kontrakt przepadnie, a my będziemy musieli płacić odszkodowanie za niewywiązanie się z umowy.
I zanim pójdziemy sprzedać nerkę, by pokryć straty, sprawdźmy, czy mimo wszystko nie mamy jakiejś alternatywy.
Rozwiązanie #1: naiwne
A co jeślibyśmy przekopiowali całość do SVG, łącznie ze stylami?
<svg xmlns="http://www.w3.org/2000/svg" class="icon-facebook" viewBox="0 0 128 128">
<defs>
<style>
.icon-facebook {
background: #3d5a98;
border-radius: 6.53px;
}
.icon-facebook:hover {
background: pink;
}
.icon-facebook:hover .cls-2 {
fill: #0f0;
}
</style>
</defs>
<path fill="#fff" id="f" class="cls-2" d="M86.48,123.17V77.34h15.38l2.3-17.86H86.48V48.08c0-5.17,1.44-8.7,8.85-8.7h9.46v-16A126.56,126.56,0,0,0,91,22.7C77.38,22.7,68,31,68,46.31V59.48H52.62V77.34H68v45.83Z"></path>
</svg>
Jeśli teraz odwołamy się do takiego obrazka poprzez img
, to… zadziała tylko połowicznie:
<img src="/images/facebook.svg" alt="Facebook" width="32" height="32">
Obrazki wstawione przez img
zawsze są statyczne. Nie pozwalają na zaawansowaną interakcję, jak choćby zareagowanie na :hover
. Na szczęście jednak istnieje inny znacznik HTML, który na taką interakcję pozwala: object
.
<object data="/images/facebook.svg" type="image/svg+xml" width="32" height="32"></object>
Działa!
Lecz zanim osiądziemy na laurach, wypada doczytać drobny druczek w umowie z klientem. A tam informacja o tym, że takich ikonek FB na stronie musi być kilka i każda musi mieć inne kolory po najechaniu. A równocześnie wszystkie te ikonki mają być oparte na tym samym pliku SVG.
Po tym jak już wyrwiemy sobie resztki włosów z głowy, czas pomyśleć nad jakimś rozwiązaniem.
Rozwiązanie #2: prawie dobre
Przekazywanie parametrów
Musimy w jakiś sposób przekazać informacje o kolorach do konkretnych ikonek na stronie. Jedyne, do czego mamy dostęp po stronie HTML-a, to URL ikonki. A to nasuwa dwa dość oczywiste rozwiązania:
- przekazanie informacji jako parametrów wyszukiwania, np.
facebook.svg?hover=red
; - przekazanie informacji jako tzw. fragmentu, np.
facebook.svg#hover=red
.
Jak się okazuje, ten problem wcale nie jest taki abstrakcyjny, bo od dawna próbuje się go rozwiązać. Pierwsze próby podjęto w roku 2009, gdy stworzono rozwiązanie opierające się właśnie na przekazywaniu informacji jako parametrów wyszukiwania. Z kolei pod koniec 2018 roku Tab Atkins stworzył nieoficjalną propozycję rozwiązania opierającego się na przekazywaniu informacji jako fragmentów. Oprócz tej oczywistej różnicy pomiędzy propozycjami istnieje jeszcze jedna, prawdopodobnie bardziej istotna: nowsza propozycja opiera się w całości na niestandardowych własnościach CSS (aka zmiennych CSS), podczas gdy starsza – na funkcji param()
w CSS.
Z racji tego, że zmienne CSS wydają się o wiele przyjemniejsze w używaniu, tak samo jak i przekazywanie informacji jako parametrów wyszukiwania, połączymy oba rozwiązania i stworzymy przekazywanie zmiennych CSS jako parametrów wyszukiwania!
http://example.com/nasz-obrazek.svg?zmienna=wartosc
SVG a JS
Pytanie jednak, jak to zrobić? Odpowiedź kryje się w starszej z propozycji. Można zauważyć, że znajdują się tam działające przykłady rozwiązania. A wszystko w oparciu o skrypt znajdujący się bezpośrednio w pliku SVG:
<script type="text/ecmascript" xlink:href="param.js" />
Z racji tego, że SVG jest poprawnym XML-em, jest tworzony dla niego DOM. A jeśli jest tworzony dla niego DOM, to najprawdopodobniej da się do niego podpiąć przez JS! Stwórzmy zatem prosty skrypt parsujący przekazywane parametry wyszukiwania na zmienne CSS.
Samo parsowanie parametrów wyszukiwania jest obecnie bardzo proste, dzięki wprowadzeniu interfejsu URLSearchParams
. Wystarczy przekazać do tego konstruktora location.search
, a więc kolekcję parametrów wyszukiwania, jakie zostały przekazane do danej strony:
const params = new URLSearchParams( location.search );
Mając ładnie przygotowaną kolekcję parametrów, nie zostaje nam nic innego niż stworzenie z każdej pary klucz-wartość odpowiedniej zmiennej CSS:
[ ...params.entries() ].forEach( ( [ param, value ] ) => { // 1
document.documentElement.style.setProperty( `--${ param }`, value ); // 2
} );
[ ...params.entries() ]
(1) tworzy nam dwuwymiarową tablicę, w której każdy element to tablica zawierająca klucz i wartość danego parametru wyszukiwania. Zatem dla takiego URL-a
http://example.com/icon.svg?hover=red&what=ever
dostaniemy taką tablicę params
:
[
[ 'hover', 'red' ],
[ 'what', 'ever' ]
]
Przy pomocy destrukturyzacji wyciągamy nazwę parametru do zmiennej param
, a wartość – do zmiennej value
. I na podstawie tych zmiennych tworzymy zmienną CSS na głównym elemencie dokumentu SVG (2).
Gotowe rozwiązanie
Jeśli połączymy to z SVG i wprowadzimy sensowne zmienne, możemy uzyskać coś takiego:
<svg xmlns="http://www.w3.org/2000/svg" class="icon-facebook" viewBox="0 0 128 128">
<defs>
<style>
.icon-facebook {
background: var( --normal-background, #3d5a98 );
border-radius: 6.53px;
}
.icon-facebook .cls-2 {
fill: var( --normal-fill, #fff );
}
.icon-facebook:hover {
background: var( --hover-background, pink );
}
.icon-facebook:hover .cls-2 {
fill: var( --hover-fill, #0f0 );
}
</style>
<script>
const params = new URLSearchParams( location.search );
[ ...params.entries() ].forEach( ( [ param, value ] ) => {
document.documentElement.style.setProperty( `--${ param }`, value );
} );
</script>
</defs>
<path id="f" class="cls-2" d="M86.48,123.17V77.34h15.38l2.3-17.86H86.48V48.08c0-5.17,1.44-8.7,8.85-8.7h9.46v-16A126.56,126.56,0,0,0,91,22.7C77.38,22.7,68,31,68,46.31V59.48H52.62V77.34H68v45.83Z"></path>
</svg>
Jak widać, wszystkie na stałe wpisane kolory w SVG zamieniliśmy na odpowiednie zmienne z domyślnymi wartościami (gdyby żadna nie została przekazana w URL-u).
Teraz możemy sterować kolorami SVG, przekazując odpowiednie parametry w URL-u:
<object data="/images/facebook.svg?normal-background=%23f00&normal-fill=blue&hover-background=%230f0&hover-fill=orange" type="image/svg+xml" width="32" height="32"></object>
Warto zauważyć, że img
po raz kolejny tutaj nie zadziała. Osadzanie obrazków w taki sposób uniemożliwia odpalenie się skryptów JS.
Dodatkowo warto zauważyć, że wszystkie parametry przekazywane do naszego SVG muszą być dostosowane do składni URL-ów, a zatem #f00
zamienia się na %23f00
. Takie kodowanie można uzyskać np. w JS przy pomocy encodeURIComponent
.
Rozwiązanie #3: dobre
A co jeśli klient jest jeszcze bardziej złośliwy i zechce jeszcze jedną ikonkę, ale równocześnie uprze się, że obydwie mają być w tym samym pliku SVG? Na szczęście sprite’y w SVG to nic nowego:
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.icon .background {
fill: var( --normal-background, #3d5a98 );
border-radius: 6.53px;
}
.icon .path {
fill: var( --normal-fill, #fff );
}
.icon:hover .background {
fill: var( --hover-background, pink );
}
.icon:hover .path {
fill: var( --hover-fill, #0f0 );
}
</style>
<script>
const params = new URLSearchParams( location.search );
[ ...params.entries() ].forEach( ( [ param, value ] ) => {
document.documentElement.style.setProperty( `--${ param }`, value );
} );
</script>
</defs>
<view id="facebook" viewBox="0 0 128 128" />
<view id="typescript" viewBox="128 0 128 128" />
<g class="icon">
<rect class="background" x="4.83" y="4.83" width="118.35" height="118.35" rx="6.53" ry="6.53" />
<path class="path" d="M86.48,123.17V77.34h15.38l2.3-17.86H86.48V48.08c0-5.17,1.44-8.7,8.85-8.7h9.46v-16A126.56,126.56,0,0,0,91,22.7C77.38,22.7,68,31,68,46.31V59.48H52.62V77.34H68v45.83Z" />
</g>
<g class="icon" style="transform: translateX( 128px );">
<rect class="background" x="22.67" y="47" width="99.67" height="73.67" />
<path class="path" d="M1.5,63.91v62.5h125V1.41H1.5Zm100.73-5a15.56,15.56,0,0,1,7.82,4.5,20.58,20.58,0,0,1,3,4c0,.16-5.4,3.81-8.69,5.85-.12.08-.6-.44-1.13-1.23a7.09,7.09,0,0,0-5.87-3.53c-3.79-.26-6.23,1.73-6.21,5a4.58,4.58,0,0,0,.54,2.34c.83,1.73,2.38,2.76,7.24,4.86,8.95,3.85,12.78,6.39,15.16,10,2.66,4,3.25,10.46,1.45,15.24-2,5.2-6.9,8.73-13.83,9.9a38.32,38.32,0,0,1-9.52-.1,23,23,0,0,1-12.72-6.63c-1.15-1.27-3.39-4.58-3.25-4.82a9.34,9.34,0,0,1,1.15-.73L82,101l3.59-2.08.75,1.11a16.78,16.78,0,0,0,4.74,4.54c4,2.1,9.46,1.81,12.16-.62a5.43,5.43,0,0,0,.69-6.92c-1-1.39-3-2.56-8.59-5-6.45-2.78-9.23-4.5-11.77-7.24a16.48,16.48,0,0,1-3.43-6.25,25,25,0,0,1-.22-8c1.33-6.23,6-10.58,12.82-11.87A31.66,31.66,0,0,1,102.23,58.93ZM72.89,64.15l0,5.12H56.66V115.5H45.15V69.26H28.88v-5A49.19,49.19,0,0,1,29,59.09C29.08,59,39,59,51,59L72.83,59Z" />
</g>
</svg>
Kod obrazków nieco się zmienił, ponieważ pojawiły się dodatkowe elementy rect
, odpowiedzialne za wyświetlanie tła.
Użycie takiego sprite’a jest bardzo podobne do użycia zwykłej ikony:
<object data="/images/sprite.svg?normal-background=%23f00&normal-fill=blue&hover-background=%230f0&hover-fill=orange#facebook" type="image/svg+xml" width="32" height="32"></object> <object data="/assets/stylowalne-ikonki-svg/sprite.svg?normal-background=%23f00&normal-fill=blue&hover-background=%230f0&hover-fill=orange#typescript" type="image/svg+xml" width="32" height="32"></object>
Jedyną zmianą jest tak naprawdę pojawienie się fragmentu w URL, który wskazuje, jaką ikonkę chcemy wyświetlić.
Rozwiązanie #4: najlepsze
A czemu by nie stworzyć Custom Elementu, który by skrzętnie ukrywał wszelkie szczegóły implementacji powyższej techniki? Wówczas wstawianie ikonek sprowadzałoby się do:
<icon- name="facebook" width="32" height="32" normal-background="#f00" normal-fill="blue" hover-background="#0f0" hover-fill="orange"></icon->
Nazwę ikonki podajemy jako atrybut [name]
, wymiary jako [width]
i [height]
, a resztę parametrów jako normalne atrybuty naszego CE. Stwórzmy zatem taki prosty CE!
class Icon extends HTMLElement { // 1
connectedCallback() { // 2
const name = this.getAttribute( 'name' ); // 5
const width = this.getAttribute( 'width' ) || 32; // 6
const height = this.getAttribute( 'height' ) || 32; // 7
const params = this.createSearchQuery();
const shadow = this.attachShadow( { mode: 'closed' } ); // 3
shadow.innerHTML = `<object data="sprite.svg?${ params }#${ this.getAttribute( 'name' ) }" type="image/svg+xml" width="${ width }" height="${ height }"></object>`; // 4
}
createSearchQuery() { // 8
const attributes = [ ...this.attributes ].filter( ( { name } ) => { //
return ![
'name',
'width',
'height'
].includes( name ); // 9
} ).map( ( { name, value } ) => { // 11
return [ name, value ]; // 10
} );
const params = new URLSearchParams( attributes ); // 12
return params.toString(); // 13
}
}
customElements.define( 'icon-', Icon ); // 14
Tworzymy nowy element HTML, Icon
(1). Następnie dodajemy akcję, która odpali się, gdy dany element zostanie dodany do DOM (2). Tworzymy wewnątrz naszego elementu Shadow DOM (3) i wstawiamy do środka znacznik object
z naszą ikoną (4). Jej nazwę (5) oraz rozmiary (6, 7) pobieramy z odpowiednich atrybutów ([name]
, [width]
, [height]
). Najbardziej skomplikowane jest przetworzenie pozostałych atrybutów na parametry przekazywane w URL-u. Robi to metoda createSearchQuery
(8). Korzystając z własności attributes
naszego elementu DOM pobieramy kolekcję wszystkich atrybutów, z których odsiewamy te niebędące parametrami (9). Następnie tworzymy tablicę dwuwymiarową z atrybutów (10) – dokładnie taką samą, jaką zwróciło nam wcześniej URLSearchParams
. Musieliśmy tutaj użyć destrukturyzacji (11), ponieważ element.attributes
jest kolekcją obiektów Attr
, a my chcemy tylko nazwę i wartość atrybutu. Uzyskaną tablicę dwuwymiarową przekazujemy do konstruktora URLSearchParams
(12), a następnie konwertujemy ten obiekt na ciąg tekstowy (13). To sprawia, że dokonujemy konwersji odwrotnej niż wcześniej: z dwuwymiarowej tablicy parametrów uzyskujemy fragment URL-a. Na koniec dodajemy nasz CE do kolekcji CE danej strony (14).
Wszystko działa, co można sprawdzić empirycznie na prostym demo.
I to by było na tyle, jeśli chodzi o stylowanie ikonek SVG. Miłego przenoszenia kodu SVG do zewnętrznych plików!
Komentarze
Przejdź do komentarzy bezpośrednio na Githubie.
Dawne komentarze
Ten blog wcześniej korzystał z systemu komentarzy Disqus. Jednakże pożegnaliśmy się i postanowiłem, że zaimportuję do nowej wersji stare komentarze z niego. Cóż, jego system eksportu na wiele nie pozwala…
Świetny wpis, tyle razy człowiek kombinował... ;)
Zasadniczo Custom Element w Twoim przykładzie mało co ukrywa, jeśli chodzi o szczegóły implementacji. Według mnie gra tutaj bardziej rolę Proxy, proponując ładniejsze API do użycia. Myślę, że klient, który byłby tak wymagający (wcześniejsze kroki), jednocześnie miałby węża w kieszeni i nie chciał słyszeć o robieniu Custom Elementów tylko dla jednego projektu, jeśli nie miałby interesu w jego releasie gdzieś dalej na Githubie, bo rozsądnym devom starczyłoby podawanie parametrów w urlu. ;-)
Tak się zastanawiam na marginesie, szukając obrony rozwiązania z CE, że fajnie byłoby gdybym mógł napisać w jakimś pliku .css icon.mojaIkonka { hover-background: red; } i ten JS by to jakoś przechwycił. Nie jestem na bieżąco z nowymi API, czy jest to możliwe bez jakichś hacków.
Ciekawy artykuł ogólnie, dzięki.
Chociaż rozumiem, że w CE da się zrobić tak, że pobiorę sobie referencję do <icon> via JS do myIcon i robiąc
myIcon.setAttribute("hover-background", "red");
wszystko się ładnie odświeży i zaaplikuje?Nie do końca. Da się obserwować tylko zmiany w atrybutach o określonych nazwach. Jeśli chcemy obserwować dowolne atrybuty, trzeba wykorzystać do tego dodatkowo `MutationObserver` (https://github.com/w3c/webc... ), co trochę zabija sens używania CE. W obecnej postaci CE aktualizuje się tylko raz, przy wstawianiu do dokumentu.
Comandeerze, nie raz pisałeś, że trzeba pamiętać o tych użytkownikach, którzy mają wyłączony javascript? A więc co z nimi? Bo ostatni "najlepszy" sposób nie działa bez js-a.
Bo to eksperyment, nie zaś produkcyjne rozwiązanie. Zauważ, że nie zajmuję się tutaj także kwestią dostępności takiego rozwiązania, co również mogłoby być wyzwaniem (bo mamy object zamiast standardowych img czy svg).
Gdybyśmy rozważali użycie tego w produkcyjnej aplikacji, trzeba by było zadbać właśnie o dostępność i być może dla CE zastosować coś w ten deseń:
<icon- name="facebook" width="32" height="32" normal-background="#f00" normal-fill="blue" hover-background="#0f0" hover-fill="orange">
<object data="/images/sprite.svg?normal-background=%23f00&normal-fill=blue&hover-background=%230f0&hover-fill=orange#facebook" type="image/svg+xml" width="32" height="32"></object>
</icon->
Nie wiedziałem, że to taki eksperyment. Dziękuję. Wyjaśnione.