Bỏ qua, tới nội dung
Kỹ thuật
6 phút đọc

Tiêu đề tiếng Việt trên web: khi font display nuốt mất dấu

Một font display cá tính, tracking âm sâu làm dấu của ầ, ữ, ợ, ệ đè lên nhau. Cách sửa: Be Vietnam Pro 800, -0.015em, line-height ~1.16, subset vietnamese.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: dấu tiếng Việt đè nhau trong một font display chật, và cách sửa cho dễ đọc
Trong bài này
  1. Vì sao tiếng Việt làm khó font display hơn tiếng Anh
  2. Bản redesign đã ship cái gì
  3. Cách sửa: một font vẽ cho tiếng Việt, và chừa khoảng thở
  4. Subset: dòng bị thiếu từ một tiếng trước đó
  5. Rồi tới lượt hero vỡ
  6. Giờ mình kiểm tra thế nào
  7. Rút ra

Trong một buổi chiều, portfolio của mình đổi font tiêu đề ba lần. 14:56 ngày 01/10/2026, mình bỏ Space Grotesk và Inter để chuyển sang Be Vietnam Pro, vì site khó đọc và dấu tiếng Việt trông chật chội. 15:34, một bản redesign mang Bricolage Grotesque vào làm font display: variable font, có trục optical size, loại font khiến headline to nhìn rất "có thiết kế". 16:06 thì Bricolage bị gỡ ra, và hai phút sau mình lại phải thu nhỏ headline ở hero vì font thay thế rộng hơn.

Site có hai ngôn ngữ. Tiêu đề tiếng Anh dùng Bricolage nhìn rất đẹp. Tiêu đề tiếng Việt thì không: mấy dấu chồng như trên ầ, ữ, ợ, ệ đâm vào dòng trên hoặc vào chữ bên cạnh. Bài này ghi lại mình đã đổi gì và vì sao, kèm đúng các con số trong commit, để lần sau có mê một font display nào thì mở trang tiếng Việt ra xem trước đã.

Vì sao tiếng Việt làm khó font display hơn tiếng Anh

Chữ Latin trong tiếng Anh gần như không dùng tới khoảng trống phía trên chiều cao chữ hoa. Tiếng Việt thì dùng liên tục, và dùng cả khoảng trống dưới baseline:

  • Dấu chồng ở trên. ầ, ẩ, ẫ, ế, ồ có dấu mũ và dấu thanh chồng lên nhau, cao vượt hẳn chữ hoa.
  • Dấu ở dưới. ợ, ệ, ạ, ụ có dấu nặng nằm dưới chữ.
  • Râu. ư và ơ có cái móc bên phải chìa sang chữ kế bên, còn ữ, ờ, ợ thì đặt thêm dấu thanh lên trên cái râu đó.

Nghĩa là một dòng tiếng Việt cần chỗ ở trên, ở dưới và hai bên. Mà tiêu đề lại chính là chỗ designer hay lấy bớt khoảng đó đi. Chữ display cỡ lớn thường được để line-height: 1 và letter-spacing âm, vì ở cỡ 80px khoảng cách mặc định trông lỏng. Với tiếng Anh, đó là lựa chọn phong cách. Với tiếng Việt, đó là va chạm: dấu nặng dưới chữ ợ ở dòng một gặp dấu mũ của ầ ở dòng hai, còn cái râu trong một từ bị tracking ép chặt thì chạm vào chữ bên cạnh.

Cùng một tiêu đề tiếng Việt ở hai cách chỉnh chật và thoáng
Dấu trên, dấu dưới, râu hai bên: tiếng Việt cần cả ba khoảng trống

Bản redesign đã ship cái gì

Đây là những gì bản redesign lúc 15:34 đưa vào globals.css và layout.tsx:

css
.text-display {
  font-family: var(--font-display); /* Bricolage, rồi tới Be Vietnam Pro */
  font-size: clamp(2.6rem, 6vw, 5.5rem);
  line-height: 1;
  letter-spacing: -0.03em;
  font-weight: 750;
}

.text-h2 {
  font-size: clamp(2rem, 3.6vw, 3.25rem);
  line-height: 1.04;
  letter-spacing: -0.035em;
}
tsx
const bricolage = Bricolage_Grotesque({
  variable: "--font-bricolage",
  subsets: ["latin", "vietnamese"],
  axes: ["opsz"],
  display: "swap",
});

Để ý cái không sai. Bricolage Grotesque có subset tiếng Việt, và mình có load nó. Glyph nào cũng render đúng font, không có ô vuông, không lẫn font. Comment trong code còn ghi rõ: "diacritics render in-family". Có đủ glyph là điều kiện cần, nhưng chưa đủ. Bản thân font được vẽ chặt và cá tính, rồi mình còn ép chặt thêm bằng line-height: 1 và tracking -0.03em đến -0.035em. Dấu thì có đủ cả. Chỉ là chúng không còn chỗ để nằm.

Ảnh bìa blog cũng dính y vậy. Tiêu đề trên cover dùng font display với line-height: 1.08 và letter-spacing: -0.03em, mà một tiêu đề tiếng Việt dài rớt xuống hai, ba dòng chính là trường hợp tệ nhất.

Cách sửa: một font vẽ cho tiếng Việt, và chừa khoảng thở

Commit 905b8bd ("Readable headings…") đổi ba thứ.

1. Tiêu đề dùng Be Vietnam Pro, chính font của body, ở weight 800. Be Vietnam Pro được thiết kế cho tiếng Việt, nên dấu chồng có khoảng cách tử tế ở mọi độ đậm. Bỏ bớt một family cũng có nghĩa mỗi trang tải ít đi một file font.

css
--font-display: var(--font-be-vietnam-pro), ui-sans-serif, system-ui, sans-serif;

2. Tracking và line-height nới ra tới mức đủ chỗ cho dấu:

TrướcSau
.text-displaylh 1, -0.03em, 750lh 1.12, -0.02em, 800
.text-h2lh 1.04, -0.035emlh 1.16, -0.015em
Tiêu đề ảnh bìalh 1.08, -0.03emlh 1.17, -0.012em

Tóm lại: line-height trong khoảng 1.12 đến 1.17, tracking quanh -0.015em. Vẫn chặt hơn chữ body, vẫn ra dáng headline, và dấu nặng dưới ợ không còn chạm dòng dưới.

3. Load đúng weight mà CSS dùng. Be Vietnam Pro trên Google Fonts không phải variable font, nên next/font chỉ tải những weight bạn liệt kê. Bản redesign đã bỏ 800 khỏi danh sách. CSS đòi 800 mà chỉ có 700 thì trình duyệt lặng lẽ dùng 700, và bạn ngồi thắc mắc sao headline nhạt hơn bản thiết kế.

tsx
const beVietnamPro = Be_Vietnam_Pro({
  variable: "--font-be-vietnam-pro",
  subsets: ["latin", "vietnamese"],
  weight: ["400", "500", "600", "700", "800"],
  display: "swap",
});

Subset: dòng bị thiếu từ một tiếng trước đó

Dòng subsets đáng có một mục riêng. Trước 14:56, site dùng Space Grotesk và Inter, cả hai đều import với subsets: ["latin"] và không gì thêm. Comment mình viết lúc thay chúng ghi rằng Be Vietnam Pro giữ khoảng cách cho dấu chồng "ở mọi weight, điều mà Space Grotesk/Inter không làm được", nên lý do chính của lần đổi đầu tiên là cách vẽ dấu chứ không phải subset. Nhưng danh sách subset cũng sai luôn.

Trong next/font, subsets quyết định phần nào của font được preload cùng trang. Bỏ vietnamese ra thì các ký tự tiếng Việt không nằm trong những file trang xin từ đầu. Với display: "swap", tiêu đề có thể vẽ trước bằng thứ đang có rồi mới tự sửa lại, nên trong một khoảnh khắc ư hay ế đến từ một font khác hẳn các chữ xung quanh. Mà headline ở hero lại là thứ đầu tiên người xem nhìn thấy.

Quy tắc của mình bây giờ: site nào có chữ tiếng Việt thì mọi font đều có subsets: ["latin", "vietnamese"], kể cả font monospace. JetBrains Mono của mình cũng có, vì ngày tháng và code trong các bài tiếng Việt dùng font đó.

Rồi tới lượt hero vỡ

Hai phút sau khi sửa tiêu đề, mình có vấn đề mới. Be Vietnam Pro ở 800 rộng hơn Bricolage. Headline ở hero, "Your app, web and backend, shipped.", nằm trong một cột cạnh ba màn hình điện thoại, và ở cỡ tối đa cũ 5.5rem thì chữ rộng hơn không còn xếp đủ ba dòng. Có text-balance, trình duyệt chia đều các từ, tức là gần như mỗi dòng một chữ: một cái tháp cao lởm chởm thay vì một headline.

Headline hero ở cỡ clamp cũ và cỡ mới
Font rộng hơn thì hạ trần cỡ chữ, đừng nới cột

Cách sửa trong b4bcfb5 là hạ trần của clamp(), chứ không nới cột:

css
.text-display {
  font-size: clamp(2.4rem, 4.8vw, 4.5rem); /* trước là clamp(2.6rem, 6vw, 5.5rem) */
  line-height: 1.08;
  letter-spacing: -0.025em;
  font-weight: 800;
}

Cả ba phần của clamp đều đổi. Mức tối đa giảm từ 5.5rem xuống 4.5rem để dòng vừa trên laptop. Phần co giãn ở giữa giảm từ 6vw xuống 4.8vw, đây mới là thứ quyết định cỡ chữ trong khoảng giữa điện thoại và desktop. Mức tối thiểu giảm nhẹ cho điện thoại nhỏ. Vậy là headline về lại ba dòng.

Nói thật thêm một điều: hero lại chặt hơn (1.08, -0.025em) so với tiêu đề các section. Dòng đó chỉ có tiếng Anh, không có dấu chồng, nên chịu được. Tiêu đề tiếng Việt vẫn giữ giá trị thoáng hơn. Nếu hero của bạn có bản dịch tiếng Việt thì cũng nên giữ giá trị thoáng.

Giờ mình kiểm tra thế nào

Lỗi typography kiểu này qua hết mọi kiểm tra tự động. Không có lỗi nào, font vẫn load, layout không xê dịch. Chỉ nhìn mới thấy. Nên mình kiểm tra bằng mắt, nhưng có bài bản:

  • Mở trang tiếng Việt, không phải tiếng Anh, ở cả bề rộng desktop lẫn điện thoại.
  • Tìm tiêu đề dài nhất bị xuống hai dòng trở lên. Đó là chỗ dấu của dòng này gặp dòng kia.
  • Dùng một câu thử có đủ mọi thứ: Những người dùng đầu tiên có râu kèm dấu (ữ), dấu mũ chồng (ầ) và dấu ở dưới. Xuống hai dòng mà vẫn đọc sạch thì cấu hình ổn.
  • Xem luôn ảnh bìa blog, vì chúng render cùng tiêu đề ở bề rộng cố định. Ảnh bìa lấy từ chính theme token của site qua script tạo ảnh bằng headless Chrome.

Rút ra

  • Font "hỗ trợ tiếng Việt" chỉ có nghĩa là có glyph. Glyph có đủ chỗ hay không là do thiết kế của font và do line-height, tracking của bạn.
  • Tiếng Việt cần chỗ ở trên (dấu chồng), ở dưới (dấu nặng) và hai bên (râu). Cấu hình display kiểu line-height: 1 và -0.03em lấy mất cả ba.
  • Tiêu đề tiếng Việt mình dùng Be Vietnam Pro 800, line-height khoảng 1.12 đến 1.17, tracking quanh -0.015em.
  • Mọi import next/font đều có subsets: ["latin", "vietnamese"], và liệt kê đủ mọi weight CSS dùng tới.
  • Font rộng hơn làm headline rớt thành mỗi dòng một chữ thì hạ mức tối đa của clamp() và phần vw ở giữa, đừng vật lộn với layout.
  • Review typography trên trang tiếng Việt, ở tiêu đề dài nhất bị xuống dòng. Không bước build nào bắt được lỗi này giùm bạn.

Site xảy ra chuyện này là vanthuongdao.id.vn; các app nó giới thiệu ở apps.vanthuongdao.id.vn.

  • #Typography
  • #Vietnamese
  • #CSS
  • #Next.js
Chia sẻXLinkedInFacebook
Dao Van Thuong

Kỹ sư mobile và fullstack ở TP. Hồ Chí Minh. Mình tự xây và phát hành các app iOS indie — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton và Stampzy.