Webfont tải chậm không chỉ làm chữ đổi kiểu. Nếu font hệ thống và font chính khác độ rộng hoặc chiều cao dòng, một tiêu đề có thể đổi cách xuống dòng, nút cao thêm vài pixel rồi đẩy cả phần nội dung bên dưới. Với tiếng Việt, câu chuyện còn rắc rối hơn vì một subset thiếu dấu có thể khiến trình duyệt ghép hai font trong cùng một từ.

Bài này dựng một fixture cục bộ với Noto Sans và Arial để đo ba cấu hình: fallback chưa chỉnh, chỉ có size-adjust, và fallback có đủ bốn metric override. Kết quả không phải công thức dùng chung cho mọi website; nó cho thấy nên đo những gì trước khi đưa bộ số vào CSS.

Vì sao cùng font-size mà giao diện vẫn nhảy?

font-size: 52px chỉ đặt kích thước em box. Hình dáng và khoảng chiếm chỗ thực tế của chữ còn phụ thuộc vào glyph advance, x-height, ascent, descent và line gap của từng font. Vì vậy Arial và Noto Sans có thể cùng 52 px nhưng tạo ra card cao khác nhau.

Khi dùng font-display: swap, trình duyệt hiển thị font dự phòng ngay rồi thay bằng webfont khi file sẵn sàng. Tài liệu web.dev về tối ưu font lưu ý rằng hai font chiếm không gian khác nhau có thể tạo layout shift. Chrome cũng mô tả size-adjust cùng các metric override như cách điều chỉnh kích thước fallback cho gần font chính hơn.

So sánh fixture desktop trước và sau khi Noto Sans tải, card tăng chiều cao và đẩy marker xuống
Baseline desktop: headline vẫn ba dòng nhưng card tăng từ 128 lên 164 px, khiến marker bên dưới dịch 36 px.

Trong ảnh fixture desktop, phần đầu là Arial trước khi font tải và phần sau là Noto Sans. Số dòng của headline giữ nguyên, nhưng card tăng từ 128 lên 164 px. Marker bên dưới bị đẩy 36 px; đây là kiểu lỗi khó thấy nếu chỉ nhìn mỗi tiêu đề.

Kiểm coverage tiếng Việt trước khi chỉnh metrics

Metric matching không sửa được font thiếu glyph. Fixture dùng corpus có ă â ê ô ơ ư đ, chữ hoa và chữ thường có dấu, tên “Nguyễn Thị Minh”, số tiền, cùng chuỗi dựng theo NFC và NFD. CSS do Google Fonts trả về tách Noto Sans thành nhiều unicode-range; với corpus này, Edge gửi hai request font, một file Latin 35.820 byte và một file Vietnamese 14.456 byte.

Google Fonts API giải thích rằng trình duyệt chỉ tải subset cần cho nội dung nhờ unicode-range. Điều đó tốt cho dung lượng, nhưng cũng có nghĩa là test bằng một câu không dấu sẽ không chứng minh được font đã đủ cho nội dung CMS tiếng Việt.

Fixture negative control thiếu subset tiếng Việt trong khi face Latin vẫn được tải
Thiếu subset tiếng Việt không nhất thiết làm chữ biến mất; trình duyệt có thể trộn fallback khác vào cùng chuỗi.

Negative control cố ý bỏ face Vietnamese nhưng vẫn giữ face Latin. Các ký tự có dấu tiếp tục hiện vì trình duyệt rơi về font khác; trang không “vỡ” rõ ràng, nhưng canvas cho kết quả độ rộng khác Arial thuần. Chỗ này rất dễ bị bỏ qua bằng mắt. Hãy sửa subset và coverage trước, rồi mới tinh chỉnh metrics.

Fixture được đo như thế nào?

Môi trường kiểm tra là Microsoft Edge 154.0.4258.53 trên Windows 11. Font Noto Sans variable được lấy từ repository Google Fonts, giấy phép SIL Open Font License 1.1; file TTF dùng đọc metrics có SHA-256 bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d. Webfont bị delay 1.200 ms để trạng thái fallback tồn tại đủ lâu.

Mỗi cấu hình chạy ba lần ở viewport 1280 × 900 và 390 × 844. Script ghi kích thước headline, card, marker, số dòng, số request font và các entry từ PerformanceObserver. Ảnh trước/sau được chụp bằng Chrome DevTools Protocol để công cụ screenshot không tự chờ document.fonts.ready.

ViewportCấu hình fallbackCard trước → sauMarker dịchLayout shift
DesktopChưa chỉnh128 → 164 px36 px0,008013
DesktopChỉ size-adjust132 → 164 px32 px0,006975
DesktopĐủ bốn descriptor136 → 164 px28 px0,003796
MobileChưa chỉnh176 → 192 px16 px0,008842
MobileChỉ size-adjust184 → 192 px8 px0,004265
MobileĐủ bốn descriptor192 → 192 px0 px0

Ba lượt trong mỗi ô trả cùng kết quả. Bộ số đầy đủ loại bỏ shift trong fixture mobile, nhưng desktop vẫn còn 28 px. Đây là phần quan trọng nhất của bài: thêm descriptor không bảo đảm CLS về 0 trên mọi layout. Width matching của một headline chưa đủ khớp toàn bộ chữ, button và line box.

Tạo fallback bằng size-adjust và metric overrides

Fixture đo độ rộng headline bằng canvas rồi tính Arial cần scale lên 105,335% để gần Noto Sans. File font có unitsPerEm 1.000, typo ascent 1.069, descent -293 và line gap 0; sau khi tính cùng hệ số scale, CSS thử nghiệm dùng:

@font-face {
  font-family: "Noto Sans Fixture";
  src: url("/fonts/noto-sans-vietnamese.woff2") format("woff2");
  font-weight: 700;
  font-display: swap;
}

@font-face {
  font-family: "Noto Sans Fallback";
  src: local("Arial");
  size-adjust: 105.335%;
  ascent-override: 101.486%;
  descent-override: 27.816%;
  line-gap-override: 0%;
}

.headline {
  font-family: "Noto Sans Fixture", "Noto Sans Fallback", sans-serif;
}

Các con số này chỉ có ý nghĩa với đúng Noto Sans, Arial, weight 700 và phép đo của fixture. Copy chúng sang Inter, Roboto hoặc một fallback khác là nhanh gọn theo kiểu… nhanh gọn tới công chuyện. Nên tính từ font thật và kiểm lại trên hệ điều hành mục tiêu.

size-adjust ở đây là descriptor trong @font-face, dùng để scale glyph outline và metrics của face dự phòng. Nó không phải thuộc tính font-size-adjust đặt lên element. Danh sách descriptor và cú pháp hiện hành có trong tài liệu @font-face của MDN và CSS Fonts Module Level 4.

So sánh fixture mobile trước và sau font swap khi fallback có đủ size-adjust và metric overrides
Cấu hình đầy đủ giữ card và marker mobile ở đúng vị trí trong cả ba lượt, dù hình dáng glyph đã đổi.

Ảnh mobile dùng cấu hình đầy đủ. Hình dáng chữ đổi khi Noto Sans tải xong, nhưng card và marker giữ nguyên vị trí trong ba lượt. Ở desktop, cùng cấu hình chỉ giảm chứ chưa triệt tiêu dịch chuyển, nên vẫn cần đo component thật thay vì kết luận từ một viewport đẹp nhất.

Chọn swap, fallback hay optional theo ưu tiên

swap ưu tiên hiển thị chữ ngay và gần như chắc chắn cho webfont cơ hội thay thế, đổi lại bạn phải quản lý shift. fallback rút ngắn thời gian chờ và cửa sổ swap. optional cho trình duyệt quyền giữ font hệ thống ở lần điều hướng đầu khi mạng chậm; layout có thể ổn định hơn nhưng nhận diện font không phải lúc nào cũng xuất hiện ngay.

Không có giá trị đúng cho mọi vị trí. Body copy dài thường cần đọc được ngay; hero mang bản sắc có thể chấp nhận chiến lược khác; button, badge và error summary lại nhạy với thay đổi chiều cao. Bài form báo lỗi dễ sửa cho thấy focus và vị trí summary quan trọng thế nào; font swap đẩy khối này ra khỏi viewport sẽ làm luồng phục hồi khó theo dõi hơn.

Với tiêu đề responsive, đừng dùng text-fit như thuốc chữa font loading. Bài thử CSS text-fit trên tiêu đề tiếng Việt xử lý chuyện co chữ theo container, còn metric matching xử lý hình học trước và sau khi face đổi. Hai lớp này có thể đi cùng nhau nhưng không thay thế nhau.

Checklist bàn giao giữa design và frontend

Vị tríCần bàn giaoĐiểm phải đoGuardrail
Body copyFont, weight, subset Việt, fallbackLine count, paragraph heightZoom 200%, text spacing
HeroBreakpoint và loading policyWrap trước/sau, LCP, shiftKhông khóa chiều cao làm cắt dấu
Button/badgeWeight thật và paddingWidth, height, label wrapKhông cắt chữ khi tăng font
Số liệuTabular figures nếu cầnĐộ rộng số, ký hiệu ₫Kiểm locale và dấu phân cách
Nội dung CMSCorpus tên riêng và chuỗi dàiCoverage, NFC/NFD, overflowKhông dùng subset text= cho copy động

Cuối cùng, chạy cả cold cache lẫn điều hướng lặp lại, và thử font bị chặn hoàn toàn. Kiểm contrast sau khi weight hoặc glyph shape đổi; bài CSS contrast-color() có thêm góc nhìn về việc không đánh giá khả năng đọc chỉ bằng màu token. Nếu fallback nhìn ổn nhưng dấu Việt bị cắt, giải pháp vẫn chưa đạt.

Bộ descriptor này đáng dùng khi webfont và fallback tạo hình học khác nhau, nhưng nó là điểm bắt đầu của phép đo chứ không phải bộ số thần chú. Hãy kiểm glyph Việt trước, tính bằng font thật, chạy nhiều viewport và giữ lại negative control. Khi mobile đã ổn mà desktop còn dịch 28 px như fixture này, dữ liệu đang nhắc rất rõ rằng cần tiếp tục chỉnh component, không nên giấu sai lệch bằng một ảnh “trông có vẻ ổn”.