BlogBlog · css

CSS Flexbox cho layout responsive: 8 pattern dùng hằng ngày và bẫy min-width: auto

8 pattern Flexbox mình dùng hằng ngày: chia cột bằng flex shorthand, navbar, card cao bằng nhau, sticky footer, card row tự wrap không cần media query. Kèm cách sửa bẫy min-width: auto làm tràn layout, class Tailwind tương ứng và bảng chọn Flexbox hay Grid.

Part of Developer Tips series
March 3, 2026
21 min read
By Leo Pham
cssflexboxresponsivefrontendlayout
Share this post:

Tuần trước mình review một trang chi tiết đơn hàng: desktop nhìn ổn, nhưng mở trên điện thoại thì cả trang bị kéo ngang được. Lý do là một đoạn <pre> chứa lệnh docker run dài và một URL webhook không có dấu cách. Thủ phạm thật không phải <pre> mà là cột nội dung flex: 1, vốn không chịu co nhỏ hơn nội dung bên trong nó. Lỗi này có tên hẳn hoi (min-width: auto), và bạn sẽ gặp nó ở mọi chỗ có text-overflow: ellipsis không chạy.

Bài này gom 8 pattern Flexbox mình dùng gần như mỗi ngày khi làm dashboard, trang sản phẩm và layout Next.js. Pattern nào cũng có code chạy được, giải thích vì sao nó chạy, và edge case hay cắn người. Cuối bài có class Tailwind tương ứng, cách debug bằng DevTools và một bảng để quyết định khi nào nên chuyển sang Grid.

TL;DR:

  • flex: 1 là 1 1 0%, flex: auto là 1 1 auto, flex: none là 0 0 auto. Muốn các cột chia đều thì dùng flex: 1, đừng dùng flex-grow: 1.
  • Flex item mặc định không co nhỏ hơn nội dung (min-width: auto). Thêm min-width: 0 (hoặc min-height: 0 với cột dọc) ở flex item chứa nội dung dài.
  • Dùng gap thay cho margin giữa các item. flex: 1 1 280px cho card row tự wrap, nhưng hàng cuối sẽ bị giãn. Khi cần lưới thẳng hàng thì dùng Grid auto-fill.
  • margin-left: auto để đẩy item sang phải, margin-top: auto để ghim nút xuống đáy card, min-height: 100dvh cho sticky footer.
  • order và row-reverse chỉ đổi thứ tự hiển thị. Tab order và screen reader vẫn đi theo DOM.

Pattern 1: chia cột co giãn bằng shorthand flex

Phần lớn bug Flexbox mình gặp bắt nguồn từ việc không nhớ shorthand flex thật sự set gì. Đây là bảng mình hay dán vào review:

Viết Tương đương Ý nghĩa thực tế
flex: initial (mặc định) 0 1 auto Rộng bằng nội dung/width, được co, không giãn
flex: 1 1 1 0% Bỏ qua kích thước nội dung, chia đều toàn bộ không gian
flex: auto 1 1 auto Bắt đầu từ kích thước nội dung, chia đều phần dư
flex: none 0 0 auto Cứng: không co, không giãn
flex: 1 1 280px grow 1, shrink 1, basis 280px "Khoảng 280px rồi co giãn", dùng cho card row

Ví dụ một thanh tab có ba nhãn dài ngắn khác nhau:

<nav class="tabs tabs--equal">
  <a href="/orders">Tổng quan</a>
  <a href="/orders/unpaid">Hoá đơn chưa thanh toán</a>
  <a href="/settings">Cài đặt</a>
</nav>

<style>
  .tabs {
    display: flex;
    gap: 8px;
  }
  /* 1 1 0%: basis bằng 0, toàn bộ chiều rộng được chia đều => 3 tab rộng bằng nhau */
  .tabs--equal > a {
    flex: 1;
    text-align: center;
  }
  /* 1 1 auto: mỗi tab bắt đầu từ độ rộng chữ, phần dư chia đều => tab dài vẫn dài hơn */
  .tabs--content > a {
    flex: auto;
  }
</style>

Với flex: 1, mọi tab có basis bằng 0. Trình duyệt lấy toàn bộ chiều rộng container trừ gap rồi chia theo tỉ lệ flex-grow (1:1:1), nên ba tab rộng bằng nhau. Với flex: auto, basis là độ rộng nội dung. Chỉ phần còn dư mới được chia đều, nên tab "Hoá đơn chưa thanh toán" vẫn rộng hơn hai tab còn lại.

Cái bẫy kinh điển là viết flex-grow: 1 và nghĩ nó bằng flex: 1. Không phải: flex-grow: 1 giữ nguyên flex-basis: auto, nên nó cư xử giống flex: auto. Nếu bạn muốn hai cột 50/50 bất kể nội dung, hãy viết flex: 1.

flex-basis và width ai thắng?

/* basis khác auto: basis là kích thước giả định, width bị bỏ qua cho trục chính */
.panel-a {
  width: 200px;
  flex-basis: 300px;
}

/* basis auto: trình duyệt dùng width làm kích thước giả định */
.panel-b {
  flex: 1 1 auto;
  width: 200px;
}

/* min-width / max-width luôn kẹp kết quả cuối cùng, sau khi đã grow/shrink */
.panel-c {
  flex: 1 1 300px;
  min-width: 240px;
  max-width: 480px;
}

Thứ tự trình duyệt xử lý là: xác định kích thước giả định (flex-basis, nếu là auto thì lấy width, nếu width cũng auto thì lấy kích thước nội dung), sau đó grow hoặc shrink, cuối cùng mới kẹp bằng min-width và max-width. Vì bước kẹp nằm cuối nên max-width là cách chắc nhất để chặn một item giãn quá rộng.

Còn một điểm hay bị quên: flex-basis đi theo trục chính. Trong container flex-direction: column, flex-basis: 300px là chiều cao chứ không phải chiều rộng.

Pattern 2: item chứa nội dung dài và bẫy min-width: auto

Đây là lỗi ở phần mở bài. Layout sidebar cố định cộng nội dung co giãn:

<div class="layout">
  <aside class="sidebar">Menu</aside>
  <main class="content">
    <pre><code>docker run --rm -e DATABASE_URL=$DATABASE_URL ghcr.io/acme/orders-api:2026.11.03 --migrate --seed=false</code></pre>
    <p>Webhook: https://api.acme.vn/v1/webhooks/invoices?status=unpaid&amp;customer_id=8f2c1a7e-4b9d-4c1e-9a3f-2b7d6e5c4a10</p>
  </main>
</div>

<style>
  .layout {
    display: flex;
  }
  .sidebar {
    flex: 0 0 240px;
  }
  .content {
    flex: 1;
    min-width: 0; /* fix chính: cho phép item co nhỏ hơn nội dung */
  }
  .content pre {
    overflow-x: auto; /* code block tự cuộn ngang bên trong */
  }
  .content p {
    overflow-wrap: anywhere; /* URL dài được phép xuống dòng ở bất kỳ ký tự nào */
  }
</style>

Bỏ dòng min-width: 0 đi, .content sẽ phình ra bằng chiều rộng dòng docker run. Kết quả là cả trang có thanh cuộn ngang dù bạn đã set overflow-x: auto cho <pre>.

Vì sao lại như vậy

Spec Flexbox định nghĩa giá trị mặc định min-width: auto cho flex item là "automatic minimum size". Hiểu đơn giản, item không được co nhỏ hơn min-content của nó, tức độ rộng của "từ" dài nhất không thể ngắt. Với <pre> (không wrap) và URL không có dấu cách, min-content chính là cả dòng. flex-shrink: 1 muốn co, nhưng bị min-width chặn lại.

Ngoại lệ: nếu item có overflow khác visible (hidden, auto, clip), automatic minimum size bằng 0. Vì vậy overflow: hidden cũng "sửa" được lỗi. Cái giá là nó cắt mất focus ring, box-shadow và dropdown tuyệt đối bên trong. Mình chỉ dùng overflow: hidden khi thật sự muốn cắt, còn lại luôn dùng min-width: 0.

Chi tiết nhỏ nhưng quan trọng: overflow-wrap: anywhere khác overflow-wrap: break-word. Cả hai đều cho ngắt URL dài khi hiển thị. Nhưng chỉ anywhere được tính khi trình duyệt đo min-content. Nếu bạn dùng break-word mà không có min-width: 0, item vẫn phình ra.

Ellipsis trong flex child

text-overflow: ellipsis không chạy trong flex item cũng chung một nguyên nhân:

<ul class="files">
  <li class="file-row">
    <span class="file-icon" aria-hidden="true"></span>
    <div class="file-info">
      <span class="file-name">bao-cao-doanh-thu-quy-3-2026-ban-cuoi-cung-da-duyet-boi-ke-toan.xlsx</span>
      <span class="file-meta">2,4 MB · cập nhật 10:42</span>
    </div>
    <button type="button">Tải</button>
  </li>
</ul>

<style>
  .file-row {
    display: flex;
    align-items: center;
    gap: 12px;
  }
  .file-icon {
    flex: none; /* div rỗng có width cố định vẫn bị co nếu không khoá lại */
    width: 32px;
    height: 32px;
    background: #e5e7eb;
    border-radius: 6px;
  }
  .file-info {
    flex: 1;
    min-width: 0; /* đặt ở flex item, không phải ở span bên trong */
  }
  .file-name {
    display: block; /* ellipsis cần phần tử block có chiều rộng bị giới hạn */
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
  }
</style>

Ba chỗ hay sai: đặt min-width: 0 lên .file-name thay vì .file-info (flex item mới là thứ bị chặn), để .file-name là inline, và quên flex: none cho icon. Div icon rỗng có content-based minimum bằng 0, nên khi tên file dài, nó bị bóp từ 32px xuống còn vài pixel. Nếu có flex lồng nhau (flex item lại là flex container), mỗi cấp nằm trên đường đi đến đoạn text đều cần min-width: 0.

Phiên bản theo chiều dọc: min-height: 0

Cùng một lỗi nhưng xảy ra ở cột dọc, thường gặp ở app chat hoặc panel log:

.app {
  display: flex;
  flex-direction: column;
  height: 100dvh;
}
.app > header {
  flex: none;
}
.workspace {
  flex: 1;
  display: flex;
  min-height: 0; /* thiếu dòng này: .workspace cao bằng toàn bộ tin nhắn, trang bị tràn */
}
.workspace > .messages {
  flex: 1;
  overflow-y: auto;
}

.workspace là flex item của cột dọc, có overflow: visible, nên min-height: auto bằng chiều cao nội dung. Một nghìn tin nhắn sẽ đẩy nó cao vượt màn hình, và .messages không bao giờ cuộn. min-height: 0 cho phép nó co về đúng phần còn lại sau header.

Pattern 3: khoảng cách bằng gap, bỏ margin

/* Cách cũ: vỡ khi wrap, item đầu hàng thứ hai vẫn có margin-left */
.tags-old > * + * {
  margin-left: 8px;
}

/* Cách đúng: gap chỉ nằm GIỮA các item, không bao giờ ở mép ngoài */
.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 12px; /* row-gap column-gap */
}

gap có ba lợi ích cụ thể. Không cần selector * + * hay :last-child. Không cần trick margin âm cho wrapper khi wrap. Item ẩn bằng display: none cũng không để lại khoảng trống thừa. Flex gap chạy trên mọi trình duyệt hiện đại (Safari hỗ trợ từ 14.1), nên không còn lý do dùng margin cho khoảng cách giữa các item.

Có một tương tác bạn nên biết: khi kết hợp với justify-content: space-between, gap trở thành khoảng cách tối thiểu. Các item dàn ra hai đầu, nhưng khi container hẹp lại thì chúng không bao giờ dính sát nhau.

Pattern 4: card row tự wrap không cần media query

<section class="cards">
  <article class="card">Đơn hàng hôm nay</article>
  <article class="card">Doanh thu tuần</article>
  <article class="card">Hoá đơn quá hạn</article>
  <article class="card">Khách hàng mới</article>
</section>

<style>
  .cards {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
  }
  .card {
    flex: 1 1 280px; /* "khoảng 280px", được giãn để lấp đầy hàng */
  }
</style>

Mỗi card bắt đầu với basis 280px. Trình duyệt xếp được bao nhiêu card vào một hàng thì xếp, phần dư chia đều cho các card trong hàng đó. Container 900px chứa được 3 card (3 × 280 + 2 × 16 = 872px), mỗi card giãn ra khoảng 289px. Trên điện thoại 360px trừ padding, mỗi hàng chỉ còn một card rộng full. Không cần dòng @media nào.

Vấn đề hàng cuối

Với 4 card trên container 900px, hàng đầu có 3 card khoảng 289px, còn card thứ tư nằm một mình ở hàng hai và giãn ra full 900px. Lý do là mỗi hàng flex được tính độc lập, flex không có khái niệm cột. Có vài cách vá: đổi thành flex: 0 1 280px (không giãn, nhưng hàng nào cũng thừa một khoảng bên phải), hoặc chèn item rỗng vô hình (hack, đừng làm). Cách đúng là dùng Grid:

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
  gap: 16px;
}

auto-fill tạo nhiều cột tối thiểu 280px nhất có thể, và card ở hàng cuối nằm đúng cột của nó. min(280px, 100%) chặn tràn khi container hẹp hơn 280px. Không có nó, minmax(280px, 1fr) sẽ ép cột rộng 280px trên màn hình 260px.

Còn auto-fit khác auto-fill ở chỗ nó gộp các cột rỗng. Với 2 card trên container rộng, auto-fit kéo 2 card ra full chiều rộng, còn auto-fill giữ chúng ở độ rộng một cột. Với lưới card dashboard, mình gần như luôn chọn auto-fill.

Flex wrap vẫn là lựa chọn đúng khi item có độ rộng tự nhiên khác nhau và bạn không cần thẳng cột: danh sách tag, chip filter, nhóm nút.

Pattern 5 và 6: dùng margin: auto để đẩy item

Navbar: đẩy nhóm bên phải

<nav class="navbar">
  <a class="logo" href="/">Acme</a>
  <a href="/orders">Đơn hàng</a>
  <a href="/invoices">Hoá đơn</a>
  <div class="navbar-right">
    <button type="button">Thông báo</button>
    <img src="/avatars/leo.png" alt="Tài khoản" width="32" height="32" />
  </div>
</nav>

<style>
  .navbar {
    display: flex;
    align-items: center;
    gap: 16px;
  }
  .navbar-right {
    margin-inline-start: auto; /* = margin-left: auto với tiếng Việt (LTR) */
    display: flex;
    align-items: center;
    gap: 12px;
  }
</style>

Auto margin trong flex hấp thụ toàn bộ không gian trống trên trục đó, và được xử lý trước justify-content. Đó là lý do bạn không cần justify-content: space-between hay một <div class="spacer">. Cũng vì vậy mà khi đã có auto margin, đặt justify-content sẽ không còn tác dụng gì. Dùng margin-inline-start thay vì margin-left để layout tự lật đúng khi có ngôn ngữ RTL.

Edge case: khi item tràn (không còn không gian trống), auto margin bằng 0. Navbar trên mobile sẽ không "đẩy" được gì nữa, nên bạn vẫn cần thiết kế lại cho màn hình hẹp (ẩn menu vào nút hamburger chẳng hạn).

Card cao bằng nhau, nút ghim ở đáy

.pricing {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  /* align-items: stretch là mặc định => các card trong cùng hàng cao bằng nhau */
}
.plan {
  flex: 1 1 260px;
  display: flex;
  flex-direction: column;
}
.plan .features {
  margin-block: 16px;
}
.plan .cta {
  margin-top: auto; /* nút luôn nằm sát đáy, dù danh sách tính năng dài ngắn khác nhau */
}

Card cao bằng nhau là nhờ align-items: stretch mặc định của container cha. Nút nằm ở đáy là nhờ card cũng là flex column, và margin-top: auto hấp thụ phần trống phía trên nút.

Có hai thứ phá pattern này. Thứ nhất là ai đó thêm align-items: center (hoặc items-center trong Tailwind) vào container cha, các card lập tức co về chiều cao nội dung. Thứ hai, khi wrap, "cao bằng nhau" chỉ đúng trong cùng một hàng flex. Nếu bạn cần cả tiêu đề, giá và danh sách tính năng thẳng hàng xuyên suốt các card, đó là việc của Grid với grid-template-rows: subgrid.

Pattern 7: sticky footer với min-height: 100dvh

Trang ít nội dung (trang 404, trang đăng nhập) thì footer vẫn phải nằm ở đáy màn hình. Trang dài thì footer nằm sau nội dung như bình thường.

body {
  margin: 0;
  min-height: 100vh; /* fallback cho trình duyệt cũ */
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}
main {
  flex-grow: 1; /* basis vẫn auto: main không bao giờ thấp hơn nội dung, chỉ giãn thêm */
}

Trong Next.js App Router, mình đặt luôn ở root layout:

// app/layout.tsx
import type { ReactNode } from 'react'
import { SiteFooter } from '@/components/site-footer'
import { SiteHeader } from '@/components/site-header'
import './globals.css'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="vi">
      <body className="flex min-h-dvh flex-col">
        <SiteHeader />
        <main className="grow">{children}</main>
        <SiteFooter />
      </body>
    </html>
  )
}

Ba quyết định trong đoạn code trên:

  • min-height chứ không phải height. Với height: 100dvh, nội dung dài hơn màn hình sẽ tràn ra ngoài body.
  • dvh chứ không phải vh. Trên trình duyệt mobile, 100vh bằng viewport lớn nhất (lúc thanh địa chỉ đã ẩn). Vì vậy trang ngắn vẫn bị cuộn một đoạn, và footer nằm dưới thanh địa chỉ. 100dvh bám theo viewport hiện tại. Nếu bạn làm phần tử full màn hình cố định (hero, modal) và không muốn nó relayout khi thanh địa chỉ co giãn, hãy dùng svh (viewport nhỏ nhất) để ổn định.
  • flex-grow: 1 thay vì flex: 1. Trên trình duyệt hiện đại cả hai đều chạy. Nhưng flex-grow giữ basis auto, nên nói rõ ý "ít nhất bằng nội dung, giãn nếu còn chỗ".

Pattern 8: order, row-reverse và vấn đề accessibility

order và flex-direction: row-reverse đổi thứ tự hiển thị mà không đổi DOM. Nghe rất tiện cho responsive, nhưng bàn phím và screen reader vẫn đi theo DOM.

<!-- DOM: Huỷ trước, Thanh toán sau -->
<div class="dialog-actions">
  <button type="button" class="btn-cancel">Huỷ</button>
  <button type="submit" class="btn-pay">Thanh toán</button>
</div>

<style>
  .dialog-actions {
    display: flex;
    gap: 8px;
  }
  /* Mobile: muốn nút chính nằm trên cùng => đảo cột */
  @media (max-width: 640px) {
    .dialog-actions {
      flex-direction: column-reverse;
    }
  }
</style>

Trên mobile, người dùng nhìn thấy "Thanh toán" ở trên, "Huỷ" ở dưới. Nhưng nhấn Tab thì focus vào "Huỷ" (phía dưới) trước rồi mới nhảy lên "Thanh toán". Screen reader cũng đọc "Huỷ" trước. Thứ tự hiển thị lệch với thứ tự focus vi phạm WCAG 1.3.2 (Meaningful Sequence) và 2.4.3 (Focus Order).

Quy tắc mình dùng:

  • Dùng order / *-reverse khi các item không tương tác và thứ tự đọc không ảnh hưởng ý nghĩa. Ví dụ: tin nhắn của chính mình đảo avatar sang phải bằng row-reverse, hay ảnh minh hoạ nằm trái hay phải nội dung.
  • Với nút, link, field form: sắp xếp DOM theo thứ tự bạn muốn người dùng đi qua, rồi dùng CSS để đặt vị trí. Ở ví dụ trên, đặt "Thanh toán" trước trong DOM thì mobile dùng column bình thường và khớp hoàn toàn. Nếu desktop bắt buộc "Huỷ" bên trái, bạn sẽ cần row-reverse, và focus đi từ phải sang trái. Lệch vẫn là lệch, nhưng nút chính được focus trước nên đỡ hại hơn. Cách sạch nhất là thống nhất với designer một thứ tự chung cho mọi breakpoint.
  • Kiểm tra bằng cách rút chuột ra và dùng Tab đi hết màn hình. Mất hai phút thôi.

CSS đang có property reading-flow (ví dụ reading-flow: flex-visual) để thứ tự đọc và Tab bám theo thứ tự hiển thị. Hiện nó mới có trên Chromium và chưa phải Baseline, nên đừng dựa vào nó cho production. Theo dõi trạng thái trên MDN.

Tailwind tương ứng và debug bằng DevTools

Pattern CSS Tailwind
Chia đều flex: 1 flex-1
Theo nội dung rồi giãn flex: auto flex-auto
Cứng flex: none flex-none hoặc shrink-0
Fix tràn min-width: 0 / min-height: 0 min-w-0 / min-h-0
Ellipsis overflow + nowrap + ellipsis truncate
URL dài overflow-wrap: anywhere wrap-anywhere (v4.1+), v3 dùng [overflow-wrap:anywhere]
Khoảng cách gap: 8px 12px gap-y-2 gap-x-3
Card row flex: 1 1 280px flex-[1_1_280px] hoặc grow basis-70 (v4)
Lưới card repeat(auto-fill, minmax(min(280px,100%),1fr)) grid grid-cols-[repeat(auto-fill,minmax(min(280px,100%),1fr))]
Đẩy sang phải margin-inline-start: auto ms-auto
Ghim xuống đáy margin-top: auto mt-auto
Sticky footer min-height: 100dvh + column flex min-h-dvh flex-col + grow
Đảo thứ tự flex-direction: row-reverse flex-row-reverse, md:flex-row

Ví dụ hàng file ở pattern 2 viết bằng Tailwind:

// components/file-row.tsx
type FileRowProps = {
  file: { name: string; sizeLabel: string; updatedAt: string }
  onDownload: () => void
}

export function FileRow({ file, onDownload }: FileRowProps) {
  return (
    <li className="flex items-center gap-3 py-2">
      <span aria-hidden="true" className="size-8 shrink-0 rounded-md bg-gray-200" />
      <div className="min-w-0 flex-1">
        <p className="truncate font-medium">{file.name}</p>
        <p className="text-sm text-gray-500">
          {file.sizeLabel} · cập nhật {file.updatedAt}
        </p>
      </div>
      <button type="button" onClick={onDownload} className="shrink-0 rounded-md px-3 py-1.5 hover:bg-gray-100">
        Tải
      </button>
    </li>
  )
}

Lưu ý basis-70 chỉ có ở Tailwind v4, vì spacing được tính động từ --spacing (70 × 0.25rem = 17.5rem = 280px). Ở v3 bạn phải viết basis-[280px]. Chi tiết về thay đổi này có trong bài Tailwind CSS v4.

Debug: tìm phần tử làm tràn trang

Khi trang bị cuộn ngang, mình dán đoạn này vào Console để tìm đúng phần tử thò ra ngoài viewport:

// Liệt kê các phần tử có mép phải vượt quá chiều rộng viewport
const viewportWidth = document.documentElement.clientWidth
for (const el of document.querySelectorAll('body *')) {
  const { right } = el.getBoundingClientRect()
  if (right > viewportWidth + 1) console.log(Math.round(right), el)
}

Phần tử in ra đầu tiên ở cấp sâu nhất thường là nội dung dài (pre, URL, bảng). Đi ngược lên cha của nó, flex item đầu tiên không có min-width: 0 chính là chỗ cần sửa.

Công cụ trực quan:

  • Chrome DevTools: trong tab Elements, phần tử display: flex có badge flex, click vào để bật overlay hiển thị item, gap và không gian trống. Trong pane Styles có icon Flexbox editor cạnh display: flex để thử justify-content / align-items bằng click. Pane Layout liệt kê mọi flex container trên trang.
  • Firefox DevTools: panel Layout có mục Flexbox. Chọn một flex item, Firefox hiển thị kích thước cơ sở, phần đã grow/shrink, và báo rõ khi item bị kẹp bởi kích thước tối thiểu. Đây là cách nhanh nhất để xác nhận đúng là lỗi min-width: auto.

Flexbox hay Grid: bảng quyết định

Tình huống Chọn Lý do
Navbar, toolbar, nhóm nút, hàng form Flexbox Một chiều, item có độ rộng tự nhiên, cần margin: auto để đẩy
Tag, chip, breadcrumb tự wrap Flexbox + flex-wrap Mỗi hàng độc lập là điều bạn muốn
Lưới card cần thẳng cột, kể cả hàng cuối Grid auto-fill + minmax Flex không có khái niệm cột
Layout trang (header, sidebar, content, footer) Grid cho khung, Flex bên trong grid-template-areas dễ đọc, đổi layout theo breakpoint gọn
Căn một phần tử vào giữa Cả hai (place-items: center hoặc flex + center) Chọn theo container đang có sẵn
Nội dung trong card cần thẳng hàng giữa các card Grid + subgrid Flex chỉ cân chiều cao trong một hàng
Danh sách có số item động, độ dài khác nhau Flexbox Kích thước theo nội dung là thế mạnh của flex
Dashboard có widget chiếm nhiều ô Grid grid-column: span 2 thay cho tính toán phần trăm

Quy tắc ngắn: nếu bạn bắt đầu tính calc(33.333% - 16px) để item thẳng cột, hãy chuyển sang Grid. Nếu bạn cần các item quyết định kích thước theo nội dung của chúng, ở lại với Flexbox.

Những lỗi thường gặp

1. flex-grow: 1 để chia cột đều.

/* Sai: basis vẫn auto, cột có nội dung dài sẽ rộng hơn */
.col {
  flex-grow: 1;
}

/* Đúng: chia đều thật và cho phép co dưới nội dung */
.col {
  flex: 1;
  min-width: 0;
}

2. Dùng width: 50% cho flex item khi đã có gap.

/* Sai: 50% + 50% + gap 16px > 100% => item bị shrink hoặc tràn nếu flex-shrink: 0 */
.half {
  width: 50%;
  flex-shrink: 0;
}

/* Đúng: để flex tự tính phần còn lại sau gap */
.half {
  flex: 1;
}

3. Sửa tràn bằng overflow: hidden. Nó chạy, nhưng dropdown, tooltip và focus ring của input bên trong bị cắt mất. Dùng min-width: 0. Chỉ dùng overflow: hidden khi bạn chủ động muốn cắt.

4. height: 100vh cho body. Nội dung dài bị tràn ra ngoài, và trên mobile footer chui xuống dưới thanh địa chỉ. Dùng min-height: 100dvh với fallback 100vh.

5. align-items: center trên hàng card. Card mất chiều cao bằng nhau. Để mặc định stretch ở container. Nếu cần căn giữa nội dung thì căn bên trong từng card.

6. Ellipsis trên phần tử inline.

/* Sai: span inline không có chiều rộng bị giới hạn => không bao giờ hiện "..." */
.file-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Đúng: block (hoặc chính nó là flex item có min-width: 0) */
.file-name {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

7. Dùng order cho nút và link để đổi thứ tự trên mobile. Thứ tự Tab sẽ lệch thứ tự nhìn. Hãy đổi DOM, hoặc chỉ đảo các phần tử không tương tác.

Checklist

  • Mọi chỗ cần cột chia đều dùng flex: 1 (flex-1), không dùng flex-grow: 1.
  • Flex item chứa text dài, <pre>, bảng hoặc URL có min-width: 0 (min-w-0); cột dọc có vùng cuộn thì có min-height: 0.
  • Icon, avatar, nút cạnh nội dung dài có flex: none hoặc shrink-0.
  • Khoảng cách giữa item dùng gap, không còn margin-left trên * + *.
  • Lưới card cần thẳng cột dùng Grid repeat(auto-fill, minmax(min(280px, 100%), 1fr)).
  • Navbar dùng margin-inline-start: auto (ms-auto) thay cho spacer div.
  • Card có nút dùng flex column + margin-top: auto, container cha không có align-items: center.
  • Root layout dùng min-h-dvh flex flex-col và main có grow.
  • Đã thử đi hết trang bằng phím Tab ở breakpoint mobile và desktop sau khi dùng order hoặc *-reverse.

Đọc thêm

Last updated: March 3, 2026

Comments