Sửa xong desktop thì mobile vỡ; vá mobile bằng một tá @media thì tablet lệch. Vấn đề thường không nằm ở CSS — nằm ở chỗ chưa có chiến lược: viết cho màn hình nào trước, breakpoint đặt ở đâu, và bao nhiêu phần layout nên tự co giãn mà không cần breakpoint nào.
Bài này trả lời ba câu đó: mobile-first thực chất là gì, vì sao nó giảm code override, và bộ công cụ CSS hiện đại giúp một layout tự thích nghi với mọi thiết bị — thay vì bạn đuổi theo từng kích thước màn hình.
1. Vì sao breakpoint theo thiết bị là sai lầm
Cách nghĩ phổ biến nhất — và sai nhất — là gán breakpoint cho tên thiết bị: 768px = tablet, 1024px = desktop. Vấn đề: có hàng nghìn kích thước viewport ngoài kia, và danh sách “thiết bị chuẩn” thì đổi mỗi năm. Bạn không bao giờ đuổi kịp.
Breakpoint đúng nằm ở nơi content gãy, không phải nơi một chiếc điện thoại nào đó có màn hình rộng bấy nhiêu. Cách tìm rất cơ học: mở trang, kéo cửa sổ trình duyệt hẹp dần cho tới khi layout bắt đầu xấu — chữ tràn, ảnh vỡ, khoảng trắng lệch — rồi đặt breakpoint đúng chỗ đó. Giá trị có lẻ như 52rem hay 840px cũng hoàn toàn hợp lệ; content quyết định con số, không phải bảng thông số thiết bị.
Suy nghĩ này đổi toàn bộ cách bạn viết CSS còn lại: thay vì hỏi “thiết bị này rộng bao nhiêu”, bạn hỏi “layout của tôi chịu được tới đâu thì gãy”.
2. Viewport meta: lỗi số 1 khiến mọi media query vô nghĩa
Trước khi bàn tới media query, có một dòng bắt buộc phải có trong phần head của trang. Thiếu nó, mọi thứ phía sau đổ sông đổ biển.
Dòng đó là thẻ meta tên viewport với nội dung width=device-width, initial-scale=1. Bạn khai báo nó ngay trong head của trang (mình mô tả bằng lời thay vì đặt vào code block để tránh làm hỏng phần demo chạy được ở dưới): thuộc tính name là viewport, thuộc tính content là width=device-width, initial-scale=1.
Nếu thiếu dòng này, trình duyệt mobile giả định trang được thiết kế cho desktop: nó render trong một viewport ảo rộng khoảng 980px rồi thu nhỏ toàn bộ để nhét vừa màn hình. Hậu quả: @media (max-width: 480px) không bao giờ khớp — vì với trình duyệt, “chiều rộng” đang là 980px, không phải chiều rộng thật của điện thoại. Chữ thì bé tí, người dùng phải zoom bằng tay.
width=device-width: đặt chiều rộng viewport bằng đúng chiều rộng thật của thiết bị.initial-scale=1: mức zoom ban đầu là 100%, không phóng to thu nhỏ.
Đây là dòng đầu tiên trong mọi checklist responsive. Sót nó thì phần còn lại của bài không có tác dụng.
3. Mobile-first thực chất là gì
Mobile-first không phải khẩu hiệu “ưu tiên mobile”. Nó là một quy tắc cơ học về thứ tự viết CSS: base style viết cho màn hình nhỏ nhất, rồi mở rộng dần lên bằng @media (min-width: ...) chồng thêm. Desktop-first thì ngược lại — viết bản to trước, rồi override xuống nhỏ bằng max-width.
3.1. Cùng một component, viết theo hai chiều
Giả sử một card có avatar và nội dung: mobile xếp dọc, desktop xếp ngang. Viết kiểu mobile-first:
1
2
3
4
5
6
7
8
9
10
11
12
13
/* Base = màn hình nhỏ nhất, không cần media query */
.card {
display: grid;
gap: 12px;
}
/* Mở rộng LÊN khi có đủ chỗ */
@media (min-width: 40rem) {
.card {
grid-template-columns: 80px 1fr;
align-items: center;
}
}Cùng component đó viết kiểu desktop-first:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Base = bản desktop */
.card {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 12px;
}
/* Override NGƯỢC xuống cho màn hình nhỏ */
@media (max-width: 40rem) {
.card {
grid-template-columns: none; /* huỷ bố cục ngang */
align-items: stretch; /* huỷ căn giữa */
}
}Đếm số dòng override: bản mobile-first chỉ thêm hai property khi màn hình rộng ra. Bản desktop-first phải huỷ hai property đã đặt (grid-template-columns: none, align-items: stretch) — mỗi thứ bạn set ở base là một thứ phải nhớ gỡ ở breakpoint. Càng nhiều state, override càng chồng chất.
3.2. Lợi ích phụ và trade-off thật
Mobile-first còn ép bạn xếp ưu tiên content ngay từ đầu: màn hình nhỏ không đủ chỗ cho mọi thứ, nên bạn buộc phải quyết định cái gì quan trọng nhất trước. Đến desktop chỉ là thêm không gian, không phải cắt bớt.
Nhưng đừng biến nó thành tôn giáo. Desktop-first vẫn hợp lý khi bạn retrofit một site desktop có sẵn: base đã là desktop rồi, viết thêm max-width để co xuống nhanh hơn là đập đi viết lại. Chiến lược ở đây là thứ tự viết, chọn theo điểm xuất phát của dự án — không phải luật bất di bất dịch.
4. Đơn vị cho breakpoint: rem hay px?
Câu hỏi kinh điển: đặt breakpoint bằng px hay rem/em? Có một hiểu lầm phổ biến rằng dùng rem trong media query sẽ bị ảnh hưởng bởi font-size của :root. Thực tế không phải vậy — và ta kiểm chứng bằng demo thật thay vì tin lời đồn.
Demo dưới đây tăng font-size của :root lên 32px, rồi hỏi trình duyệt xem (min-width: 25em) có khớp không. Nếu media query dùng font-size của root thì 25em = 25 × 32 = 800px; nếu nó dùng font-size mặc định của trình duyệt (16px) thì 25em = 400px:
1
2
3
4
5
6
7
8
9
10
11
12
<div id="out" style="font-family: sans-serif; padding: 12px"></div>
<script>
document.documentElement.style.fontSize = "32px";
const q = matchMedia("(min-width: 25em)");
const rootPx = getComputedStyle(document.documentElement).fontSize;
console.log("iframe innerWidth =", innerWidth, "px");
console.log("font-size của :root =", rootPx);
console.log("(min-width: 25em) khớp?", q.matches);
console.log("=> 25em theo root(32px) = 800px | 25em theo 16px mặc định = 400px");
document.getElementById("out").textContent =
"(min-width: 25em) khớp? " + q.matches + " tại innerWidth " + innerWidth + "px";
</script>Kết quả in ra: tại iframe rộng 630px, (min-width: 25em) khớp true. Nếu media query dùng font-size 32px của root thì ngưỡng phải là 800px và 630px sẽ không khớp. Việc nó khớp chứng minh ngưỡng thật là 400px (25em × 16px) — media query bỏ qua font-size của root và luôn tính em/rem theo font-size mặc định của trình duyệt (16px). Đây là hành vi theo spec: bên trong media query, em và rem đều quy về giá trị initial của font-size, không phải giá trị bạn set cho :root.
Vậy chọn gì? Khuyến nghị: dùng rem cho breakpoint. Lý do không phải vì root — mà vì khi người dùng chỉnh font-size mặc định của trình duyệt to lên (một cách hỗ trợ người khiếm thị), breakpoint tính bằng rem/em sẽ dịch theo, còn px thì đứng yên. Trong nội dung layout, rem tôn trọng zoom và setting của user; px chỉ nên dùng cho những giá trị thật sự cố định như đường viền 1px.
5. Fluid trước, breakpoint sau
Đây là phần đổi tư duy lớn nhất: media query là công cụ cuối cùng, không phải phản xạ đầu tiên. Rất nhiều layout tự co giãn mượt mà mà không cần một breakpoint nào, nếu bạn để chúng “chảy” theo không gian. Cách tiếp cận này hay được gọi là intrinsic design.
5.1. clamp() cho type và spacing
clamp(min, preferred, max) nhận ba tham số: giá trị nhỏ nhất, giá trị “ưa thích” (thường tính theo vw để co giãn theo viewport), và giá trị lớn nhất. Trình duyệt giữ giá trị trong khoảng [min, max] và cho nó trôi theo preferred ở giữa:
1
2
3
4
5
6
7
8
9
.title {
/* nhỏ nhất 1.5rem, lớn nhất 3rem, ở giữa co giãn theo viewport */
font-size: clamp(1.5rem, 1rem + 4vw, 3rem);
}
.section {
/* padding cũng fluid, không cần breakpoint */
padding: clamp(1rem, 5vw, 4rem);
}Một dòng clamp() thay cho ba bốn breakpoint chỉnh font-size. Chữ to dần mượt theo màn hình thay vì nhảy bậc thang.
5.2. flex-wrap và grid auto-fit tự xuống hàng
Với layout dạng lưới card, cặp grid-template-columns: repeat(auto-fit, minmax(...)) cho các card tự reflow — tự quyết định mỗi hàng chứa bao nhiêu cột dựa trên chỗ trống, không cần bạn viết breakpoint:
1
2
3
4
5
6
.grid {
display: grid;
gap: 12px;
/* mỗi cột tối thiểu 150px, có bao nhiêu chỗ thì nhét bấy nhiêu cột */
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}Demo dưới render 6 card trong lưới đó và log lại số cột trình duyệt tự tính được ở chiều rộng của iframe:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<style>
.grid { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); font-family: sans-serif; }
.card { background: #1a2c08; color: #c7ff50; padding: 16px; border-radius: 6px; text-align: center; }
</style>
<div class="grid" id="g">
<div class="card">1</div><div class="card">2</div><div class="card">3</div>
<div class="card">4</div><div class="card">5</div><div class="card">6</div>
</div>
<script>
const cols = getComputedStyle(document.getElementById("g")).gridTemplateColumns;
console.log("iframe innerWidth =", innerWidth, "px");
console.log("grid-template-columns (resolved) =", cols);
console.log("số cột =", cols.split(" ").length);
</script>Để ý output in ra giá trị đã resolve thành px (ở chiều rộng iframe này là 190.795px 190.795px 190.795px — 3 cột), không phải 1fr như bạn viết, vì getComputedStyle trả về giá trị tính toán cuối cùng. Kéo cửa sổ hẹp lại thì số cột tự giảm, không cần bạn động vào CSS.
Về cơ chế bên trong của Flexbox và Grid — trục chính/phụ, đơn vị fr, hay grid-template-areas (riêng của Grid) — thì đủ cho một chủ đề chuyên sâu riêng; ở đây ta chỉ dùng chúng như công cụ fluid, không dạy lại.
6. Media query hiện đại: rộng hơn chiều rộng màn hình
“Responsive” không chỉ là chiều rộng. Media query hiện đại có cú pháp gọn hơn và nhiều media feature vượt ra ngoài kích thước.
6.1. Range syntax dễ đọc hơn min-/max-
Thay vì (min-width: 48rem), giờ bạn viết thẳng bằng toán tử so sánh: (width >= 48rem). Một khoảng kín cũng gọn trong một biểu thức: (40rem <= width <= 60rem). Cú pháp này đạt Baseline từ tháng 3/2023 và đến nay hỗ trợ rộng rãi (Chrome, Edge, Firefox, Safari), nên dùng thoải mái cho dự án mới.
6.2. Các media feature ngoài width
Responsive còn là đáp ứng khả năng và sở thích của thiết bị:
(hover: hover)— thiết bị có con trỏ hover thật (chuột) hay không. Đừng giấu chức năng sau:hovertrên màn hình touch, vì ở đó không có trạng thái hover.(prefers-reduced-motion: reduce)— người dùng bật giảm chuyển động; tắt animation cho họ.(prefers-color-scheme: dark)— người dùng thích giao diện tối.
Demo dưới hỏi trình duyệt trong iframe xem từng điều kiện có khớp không (range syntax lẫn media feature):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div id="out" style="font-family: sans-serif; padding: 12px; line-height: 1.6"></div>
<script>
const checks = {
"(width >= 48rem)": matchMedia("(width >= 48rem)").matches,
"(width < 48rem)": matchMedia("(width < 48rem)").matches,
"(hover: hover)": matchMedia("(hover: hover)").matches,
"(prefers-reduced-motion: reduce)": matchMedia("(prefers-reduced-motion: reduce)").matches,
};
console.log("iframe innerWidth =", innerWidth, "px");
for (const [feature, matched] of Object.entries(checks)) {
console.log(feature, "=>", matched);
}
document.getElementById("out").innerHTML = Object.entries(checks)
.map(([f, m]) => f + " → " + m)
.join("<br>");
</script>Việc range syntax được matchMedia parse và trả kết quả đúng chứng minh trình duyệt hỗ trợ cú pháp này. Kết quả true/false của mỗi feature phản ánh chiều rộng và cấu hình của chính iframe — mỗi môi trường một khác, nên hãy đọc từ output thật.
7. Container queries: component tự đáp ứng theo ô chứa
Đây là mảnh ghép mà media query không giải được. Media query luôn hỏi về viewport — chiều rộng toàn màn hình. Nhưng một card có thể nằm trong khu vực nội dung rộng 800px, hoặc nhét trong sidebar 300px, trên cùng một trang. Card trong sidebar cần layout “mobile” dù màn hình là desktop.
Container queries cho component đáp ứng theo ô chứa nó, không theo viewport. Bạn khai báo một phần tử là container bằng container-type: inline-size, rồi con của nó dùng @container (min-width: ...):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.box {
container-type: inline-size; /* biến .box thành container để đo */
}
.card {
display: grid;
gap: 8px;
}
/* Hỏi chiều rộng của .box, KHÔNG phải viewport */
@container (min-width: 400px) {
.card {
grid-template-columns: 120px 1fr;
align-items: center;
}
}Demo đắt giá nhất của bài: cùng một markup card đặt trong hai container chiều rộng cố định khác nhau (220px và 420px), trên cùng một viewport. Bạn thấy hai hình thái xuất hiện cùng lúc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<style>
.box { container-type: inline-size; border: 1px solid #4aa8ff; border-radius: 8px; margin-bottom: 10px; font-family: sans-serif; }
.narrow { width: 220px; }
.wide { width: 420px; }
.card { display: grid; gap: 8px; padding: 10px; }
.thumb { background: #0e2233; color: #9ecbff; padding: 20px; text-align: center; border-radius: 6px; }
.body { color: #e6edf3; }
@container (min-width: 400px) {
.card { grid-template-columns: 120px 1fr; align-items: center; }
}
</style>
<div class="box narrow">
<div class="card"><div class="thumb">IMG</div><div class="body">Container 220px → xếp dọc</div></div>
</div>
<div class="box wide">
<div class="card"><div class="thumb">IMG</div><div class="body">Container 420px → xếp ngang</div></div>
</div>
<script>
const narrow = getComputedStyle(document.querySelector(".narrow .card")).gridTemplateColumns;
const wide = getComputedStyle(document.querySelector(".wide .card")).gridTemplateColumns;
console.log("card trong container 220px, cột =", narrow);
console.log("card trong container 420px, cột =", wide);
</script>Hai card có markup y hệt nhau, chung một viewport, nhưng card trong 220px xếp dọc còn card trong 420px xếp ngang — vì @container đo ô chứa chứ không đo màn hình. Output log lại grid-template-columns đã resolve của từng card (giá trị px) để bạn thấy rõ chỉ card trong container rộng mới nhận bố cục hai cột.
Về đơn vị, container queries đi kèm nhóm đơn vị đo theo container: cqw/cqh (1% chiều rộng/cao container) và cqi/cqb (theo trục inline/block). @container và các đơn vị này đạt Baseline từ 2023 và nay đã hỗ trợ trên mọi trình duyệt hiện đại.
8. Ảnh responsive: đúng kích thước, không layout shift
Ảnh là thủ phạm số một của layout vỡ và tải chậm. Bốn công cụ cần nắm:
max-width: 100%— ảnh không bao giờ tràn khỏi khung chứa.srcset+sizes— cung cấp nhiều phiên bản kích thước, để trình duyệt tự chọn cái vừa nhất.<picture>— đổi hẳn ảnh theo điều kiện (art direction), ví dụ ảnh dọc cho mobile.aspect-ratio— giữ chỗ trước, chống layout shift khi ảnh chưa tải xong.
8.1. srcset và sizes: để trình duyệt chọn
Với srcset dùng width descriptor (480w, 960w…), bạn khai báo mỗi file rộng bao nhiêu pixel thật; sizes cho biết ảnh sẽ hiển thị to cỡ nào ở từng điều kiện. Trình duyệt kết hợp hai thông tin đó cùng mật độ điểm ảnh của màn hình (DPR) để tải đúng file — không phí băng thông tải ảnh 1440px cho khung 400px.
Markup thật trông như sau (mô tả bằng lời để không lẫn với block chạy được ở dưới): một thẻ img với srcset="hero-480.jpg 480w, hero-960.jpg 960w, hero-1440.jpg 1440w" và sizes="(min-width: 40rem) 400px, 100vw". Demo dưới tái hiện đúng thuật toán trình duyệt dùng để chọn file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// srcset: 3 file với width descriptor (px thật của mỗi file)
const candidates = [
{ src: "hero-480.jpg", width: 480 },
{ src: "hero-960.jpg", width: 960 },
{ src: "hero-1440.jpg", width: 1440 },
];
// sizes cho biết ảnh hiển thị rộng 400px; màn hình có DPR = 2
const displayWidth = 400;
const dpr = 2;
const needed = displayWidth * dpr; // 800px vật lý
// Trình duyệt chọn file NHỎ NHẤT vẫn đủ độ rộng vật lý
const chosen = candidates.find((c) => c.width >= needed) ?? candidates.at(-1);
console.log("Cần tối thiểu", needed, "px vật lý =", displayWidth, "px ×", dpr, "DPR");
console.log("Trình duyệt chọn:", chosen.src, "(" + chosen.width + "w)");Chạy thử: cần 800px vật lý (400px × 2 DPR), nên trình duyệt chọn hero-960.jpg (960w) — file nhỏ nhất vẫn đủ nét, bỏ qua hero-1440.jpg để tiết kiệm băng thông. Trên màn hình DPR 1, cùng khung 400px đó nó sẽ hạ xuống hero-480.jpg.
8.2. aspect-ratio chống layout shift
Khi ảnh chưa tải xong, nếu không khai báo kích thước, trình duyệt để chiều cao bằng 0 rồi nhảy khi ảnh về — đó là layout shift. aspect-ratio giữ sẵn tỉ lệ khung ngay từ đầu:
1
2
3
4
5
6
7
8
<div id="ph" style="max-width: 100%; aspect-ratio: 16 / 9; background: #241832; color: #d4b3ff; display: grid; place-items: center; border-radius: 8px; font-family: sans-serif">
Khung 16 / 9 giữ sẵn chỗ
</div>
<script>
const r = document.getElementById("ph").getBoundingClientRect();
console.log("width =", Math.round(r.width), "px, height =", Math.round(r.height), "px");
console.log("tỉ lệ width/height =", (r.width / r.height).toFixed(3), "≈ 16/9 =", (16 / 9).toFixed(3));
</script>Khung giữ đúng tỉ lệ 16/9 dù chưa có ảnh; khi ảnh về đúng khung đó, không có cú nhảy nào. Phần sâu hơn về CLS, LCP và cách trình duyệt decode ảnh nằm ở bài Critical Rendering Path: Hành Trình Từ HTML Đến Pixels — đọc kèm nếu bạn muốn tối ưu thời gian hiển thị. Đừng quên loading="lazy" cho ảnh nằm dưới màn hình đầu để hoãn tải cho tới khi cần.
9. Bẫy mobile thực địa và checklist chiến lược
Vài cái bẫy chỉ lộ ra khi chạy trên điện thoại thật, không thấy trên máy bàn:
100vhbị URL bar ăn mất. Trên mobile,100vhtính theo viewport khi thanh địa chỉ đã ẩn, nên phần tử cao100vhbị URL bar che mất một khúc. Dùng100dvh(dynamic — co theo trạng thái thanh bar),svh(small — luôn tính lúc bar hiện), hoặclvh(large — lúc bar ẩn). Nhóm đơn vị này đã là Baseline, hỗ trợ mọi trình duyệt hiện đại từ 2022.- Touch target quá nhỏ. Nút bấm bé thì ngón tay bấm trượt. Con số chuẩn tuỳ nguồn: WCAG 2.5.8 Target Size (Minimum), mức AA của WCAG 2.2, yêu cầu tối thiểu
24×24CSS px (có ngoại lệ nếu có khoảng cách đủ giữa các target); mức AAA (2.5.5) chặt hơn,44×44. Apple Human Interface Guidelines khuyến nghị44×44 pt, còn Material Design là48×48 dp. Nhắm khoảng44–48pxcho vùng chạm chính là an toàn trên mọi nền tảng. - Notch và safe area. Trên máy có tai thỏ, dùng
env(safe-area-inset-*)để nội dung không bị khuyết che.
Chốt lại bằng checklist chiến lược, làm theo thứ tự:
- Đặt viewport meta
width=device-width, initial-scale=1— không có thì bỏ hết phần dưới. - Viết base cho mobile trước, dùng
remcho breakpoint để tôn trọng setting của user. - Fluid hết mức có thể:
clamp()cho type/spacing,flex-wrap,grid auto-fit+minmax()— trước khi nghĩ tới breakpoint. - Chỉ thêm breakpoint ở nơi content gãy, và viết theo chiều
min-width. - Dùng container query cho component tái sử dụng ở nhiều ô chứa khác nhau.
- Ảnh thì
srcset+sizes+aspect-ratiođể chọn đúng file và chống layout shift. - Test ở nhiều chiều rộng, kể cả giá trị lẻ.
Một lưu ý thật lòng về bước test: device toolbar trong DevTools chỉ mô phỏng chiều rộng, không phải thiết bị thật — hành vi URL bar, touch, DPR có thể khác. Cứ smoke ở bộ 320 / 375 / 768 / 1024 / 1280, rồi kéo cửa sổ tự do để tìm điểm gãy thật.
Bắt tay ngay: mở project bạn đang làm, kéo cửa sổ trình duyệt hẹp dần cho tới khi layout đầu tiên xấu đi, rồi đặt lại breakpoint đúng chỗ content gãy đó — thay vì con số 768px quen tay. Đó là bước đầu để chuyển từ vá lỗi bị động sang một chiến lược responsive thật sự.