Typografia Wydajność Design

Tekst dopasowany do szerokości kontenera w czasie builda, dzięki fontowi zmiennemu

Jak moje imię i nazwisko wypełniają szerokość strony bez JavaScriptu: metryki fontu liczone w buildzie, oś wdth, jednostki cqi i font-stretch.

Autor: Mateusz Muszarski Zaktualizowano 5 min czytania In English

Moje imię i nazwisko na stronie głównej wypełniają szerokość od krawędzi do krawędzi i żaden skrypt ich nie skaluje. W czasie builda mierzę font zmienny Anybody na obu końcach osi szerokości, a potem wyliczam rozmiar i szerokość każdej linii tak, żeby wypełniała kontener. Strona dostaje wynik jako jednostki cqi i wartości font-stretch, więc przeglądarka rysuje nagłówek od razu w docelowym rozmiarze, z samego HTML i CSS.

TL;DR

  • Dlaczego: nagłówek jest elementem LCP, więc dopasowanie skryptem oznacza albo przesunięcie układu, albo późniejsze LCP.
  • Jak: skrypt w buildzie mierzy Anybody przy wdth 50 i 150. Szerokości pomiędzy są prawie liniowe (odchylenie najwyżej 0,06%), więc rozmiar i font-stretch każdej linii to jedno dzielenie.
  • Efekt: przy ładowaniu nic nie jest mierzone, a animacja liter czeka na pierwszy ruch czytelnika.

Dlaczego nie dopasować tekstu JavaScriptem po załadowaniu?

Typowe podejście to skrypt: wyrenderuj tekst, zmierz go, zwiększaj font, aż wypełni pudełko, powtarzaj przy każdej zmianie rozmiaru okna. Tutaj nagłówkiem jest moje imię i nazwisko, największa rzecz na pierwszym ekranie, więc to element Largest Contentful Paint (LCP). Dopasowanie skryptem zostawia dwie złe opcje.

  • Najpierw pokaż, potem dopasuj. Tekst pojawia się w zgadniętym rozmiarze, a potem skacze, razem ze wszystkim pod nim. web.dev nazywa to przesunięciem układu: widoczny element zmienia pozycję między kolejnymi klatkami.
  • Ukryj, dopóki nie pasuje. web.dev wyklucza z LCP elementy z opacity: 0, więc LCP czeka wtedy na skrypt, font i pomiar.

Nie chciałem żadnej z nich. Dlatego dopasowanie dzieje się, zanim strona w ogóle powstanie.

Co umożliwia dopasowanie w czasie builda?

Anybody to font zmienny na licencji OFL, z osią szerokości (wdth) od 50 do 150 i osią grubości od 100 do 900. W CSS osią wdth steruje font-stretch: MDN podaje tę właściwość przy tej osi i zaleca właściwości wysokiego poziomu zamiast font-variation-settings. Specyfikacja CSS Fonts zmieniła już nazwę font-stretch na font-width, ale według MDN font-width ma wciąż ograniczoną dostępność w przeglądarkach, więc strona zostaje przy starej nazwie.

Kluczowy fakt: przy stałej grubości szerokość każdego znaku i każda para kerningowa zmieniają się liniowo względem wdth. Znasz szerokość linii przy 50 i przy 150, to znasz ją dla każdej wartości pomiędzy.

„Liniowo” wymaga przypisu. Font ma master przy 100, więc ściśle biorąc wykres to dwa proste odcinki, które się tam spotykają. Sprawdziłem to fontkitem w dziewięciu punktach od 50 do 150: dla MATEUSZ, MUSZARSKI i par z mocnym kerningiem, jak AV i TO, rzeczywista szerokość odbiega od prostej najwyżej o 0,06%. Dopasowanie celuje w 99,5% kontenera, więc to się mieści.

Co mierzy build?

  1. 01 bun run fonts Mierzy każdy znak nagłówków przy wdth 50 i 150.
  2. 02 Plik metryk Dwie liczby na znak i parę kerningową, w repozytorium.
  3. 03 Fit.astro Liczy rozmiar i szerokość każdej linii w czasie builda.
  4. 04 HTML i CSS Rozmiary w cqi i wartości font-stretch, nic do mierzenia.

Skrypt Node, scripts/fonts.mjs, uruchamiam wtedy, gdy zmienia się krój, a nie przy każdym deployu. Robi subset fontu, generuje reguły @font-face i plik metryk dla każdego znaku, który może trafić do dopasowanej linii: wielkich liter łacińskich, polskich i hiszpańskich, cyfr i kilku znaków interpunkcyjnych. Dziś to 72 znaki i 406 par kerningowych.

// uproszczone z scripts/fonts.mjs
const at = [50, 150].map((wdth) => font.getVariation({ wght: 900, wdth }));
const em = (units) => units / font.unitsPerEm;

for (const c of DISPLAY) adv[c] = at.map((f) => em(f.layout(c).advanceWidth));

for (const a of DISPLAY)
  for (const b of DISPLAY) {
    // kerning = szerokość pary minus oba znaki osobno
    const k = at.map((f) => f.layout(a + b).advanceWidth - f.layout(a).advanceWidth - f.layout(b).advanceWidth);
    if (k.some((v) => Math.abs(v) > 0.5)) kern[a + b] = k.map(em);
  }

Wynik to zwykły obiekt z liczbami w em. Leży w repozytorium, więc build strony w ogóle nie dotyka narzędzi do fontów.

Jak liczę każdą linię?

Z metrykami w ręku szerokość linii przy dowolnym wdth to suma interpolacji. Samo dopasowanie ma dwa kroki.

  1. Weź najszerszą linię przy wybranej szerokości bazowej. Jej szerokość w em wyznacza rozmiar fontu: linia o szerokości 4,30 em wypełnia kontener przy 100 / 4,30, czyli około 23,2 cqi.
  2. Dla każdej pozostałej linii wylicz wdth, przy którym będzie dokładnie tak szeroka. Szerokość jest liniowa względem wdth, więc to jedno dzielenie, a nie szukanie.
// uproszczone z src/lib/fit.ts
function fitLines(lines: string[], base: number, locale = "en", fill = 0.995) {
  const upper = lines.map((line) => line.toLocaleUpperCase(locale));
  const target = Math.max(...upper.map((line) => lineEm(line, base)));
  const wdth = upper.map((line) => {
    const lo = lineEm(line, 50);
    const hi = lineEm(line, 150);
    return clamp(50 + ((target - lo) / (hi - lo)) * 100, 50, 150);
  });
  return { size: (100 * fill) / target, wdth }; // rozmiar w cqi
}

Linie najpierw dostają wielkie litery według reguł języka strony, a fill równe 0,995 zostawia zapas na zaokrąglenia między buildem a przeglądarką.

Jak wynik trafia do CSS?

Rozmiar jest w cqi. MDN definiuje 1 cqi jako 1% rozmiaru kontenera zapytań w osi inline. Każdy dopasowany blok siedzi w opakowaniu z container-type: inline-size, więc tekst skaluje się z własnym pudełkiem, a nie z oknem. Jeden komponent ustawia wszystkie plakatowe tytuły na stronie: imię i nazwisko na głównej i w CV, tytuły rozdziałów, nagłówek bloga, 404.

Trzy ekrany telefonu: strona główna i CV z imieniem i nazwiskiem w wysokich, zwężonych wersalikach oraz lista wpisów z szeroko złożonym słowem BLOG Telefony, 390 px
Ten sam komponent na telefonach, z ograniczonym ruchem, żeby było widać samo dopasowanie z builda. Niska szerokość bazowa daje wysokie imię i nazwisko, a tytuł bloga prosi o szeroką.

Szerokości trafiają do HTML jako procenty font-stretch, po jednym na linię. Na telefonie szerokie ustawienie daje niski, cienko wyglądający tekst, więc każdy blok liczę dwa razy, domyślnie z bazowym wdth 66 poniżej 700 px i 96 powyżej. Który wariant obowiązuje, decyduje container query.

.fit-host { container-type: inline-size; }
.fit { font-size: calc(var(--fs-n) * 1cqi); line-height: 0.8; }
.line { display: block; width: max-content; white-space: nowrap; font-stretch: var(--w-n); }

@container (min-width: 700px) {
  .fit { font-size: calc(var(--fs-w) * 1cqi); }
  .line { font-stretch: var(--w-w); }
}

Dla mojego imienia i nazwiska build zapisuje w wąskim wariancie 23,118 cqi, MATEUSZ przy 84,97% i MUSZARSKI przy 66%. W szerokim to 16,115 cqi, ze 121,33% i 96%.

Co się dzieje po pierwszym ruchu?

Akordeon liter
Imię i nazwisko na stronie głównej, gdy przesuwa się nad nimi kursor, nagrane przy 1200 px. Każda litera zmienia szerokość, żadna linia nie.

Małe ulepszenie zamienia dopasowany tytuł w akordeon liter: litery przy kursorze się rozszerzają, pozostałe zwężają, a linia nadal trzyma krawędzie. Bez kursora przez imię i nazwisko na stronie głównej przechodzi powolna fala. To ta sama liniowa zależność, tylko na poziomie pojedynczych liter.

  • Każda linia jest dzielona na osobne elementy dla liter, ukryte przed technologiami asystującymi za jedną wizualnie ukrytą kopią tekstu, więc czytnik ekranu przeczyta imię i nazwisko raz.
  • Każdą literę mierzę raz przy wdth 50 i raz przy 150: jej szerokość i to, ile pikseli zyskuje na jednostkę wdth.
  • W każdej klatce liczona jest krzywa dzwonowa wokół kursora albo fali. Zanim litery się wzdłuż niej rozszerzą, odejmuję średnią krzywej ważoną nachyleniem każdej litery, więc zmiany sumują się do zera. Gdy litera dojdzie do 50 albo 150, powolna korekta ściąga linię z powrotem do krawędzi.

Pierwsza wersja startowała, gdy tylko strona się załadowała: podział, pomiar, a potem zmiana stylu liter w każdej klatce. Nagranie wydajności z 7 października, na telefonie z throttlingiem CPU, pokazało, że takie pętle przy ładowaniu zjadają główny wątek strony głównej. Teraz akordeon czeka na pierwsze przewinięcie, dotyk, klawisz albo ruch myszy i dzieli blok dopiero wtedy, gdy ten zbliża się do ekranu. Klatki nigdy nie mierzą liter: jedyny odczyt układu to jeden prostokąt na widoczny blok, przed zapisem jakiegokolwiek stylu.

Nie startuje przy prefers-reduced-motion: reduce i zatrzymuje się poza ekranem (IntersectionObserver), po wciśnięciu przycisku pauzy na stronie i gdy nic się nie rusza: to zasady z tekstu o ruchu, który szanuje ludzi. Test w Playwright sprawdza przy 1440 px, czy każda linia wypełnia ponad 97% kontenera, przed startem akordeonu i po nim. Sprawdza też, czy nagłówek nadal czyta się jako moje pełne imię i nazwisko.

Ograniczenia

  • Nieznany znak wywraca build. Dopasowana linia ze znakiem, którego nie ma w metrykach, rzuca błąd. Wolę to niż linię, która po cichu jest o kilka procent za krótka; naprawa to dopisać znak i uruchomić skrypt fontów ponownie.
  • Bardzo nierówne linie nie mogą obie trzymać krawędzi. Oś daje zakres mniej więcej 3 do 1 (MATEUSZ ma 2,51 em przy 50 i 7,65 em przy 150), więc linia z dwiema literami obok linii z piętnastoma zostanie krótsza.
  • Font zastępczy nie ma osi szerokości. Zanim dotrze Anybody, przeglądarka pokazuje lokalny krój dopasowany metrykami, np. Arial Bold, a MDN zaznacza, że font-stretch nic nie robi bez krojów zwężonych ani rozszerzonych. Rozmiar się zgadza (wynika z cqi), linie są nierówne. Ile to trwa na wolnym łączu, nie mierzyłem.
  • Metryki należą do jednego pliku fontu. Wystarczy zmienić krój, grubość albo subset, a liczby przestają się zgadzać; nic nie sprawdza, czy ktoś ponownie uruchomił skrypt.

Ten sam komponent ustawia imię i nazwisko w CV na stronie, którego PDF się z nim nie rozjeżdża, a resztę historii budowy opisałem w studium przypadku. Jeśli szukasz kogoś do frontendu, w którym takie detale mają znaczenie, tu znajdziesz kontakt.

Źródła

  1. CSS container queries (MDN) (otwiera się w nowej karcie) (dostęp: )
  2. font-stretch (MDN) (otwiera się w nowej karcie) (dostęp: )
  3. font-width (MDN) (otwiera się w nowej karcie) (dostęp: )
  4. font-variation-settings (MDN) (otwiera się w nowej karcie) (dostęp: )
  5. Anybody, Etcetera Type Co. (GitHub) (otwiera się w nowej karcie) (dostęp: )
  6. Anybody METADATA.pb, osie i licencja (google/fonts) (otwiera się w nowej karcie) (dostęp: )
  7. fontkit (GitHub) (otwiera się w nowej karcie) (dostęp: )
  8. Largest Contentful Paint (LCP), web.dev (otwiera się w nowej karcie) (dostęp: )
  9. Cumulative Layout Shift (CLS), web.dev (otwiera się w nowej karcie) (dostęp: )
  10. Intersection Observer API (MDN) (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy