jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

CSS Cascade, Layers & Specificity — The Full Resolution Order in 2026

How browsers resolve CSS in 2026: origin, @layer, (a,b,c) specificity, :is/:where/:has(), and architecture patterns that end specificity wars.

Tại sao Cascade vẫn quan trọng

Mỗi stylesheet bạn ship là một cuộc thi. Hàng chục — đôi khi hàng nghìn — rule cùng nhắm vào một phần tử. Trình duyệt không đoán; thuật toán cascade xác định để chọn đúng một declaration thắng cho mỗi property.

Nếu bạn chỉ nhớ “ID thắng class”, bạn sẽ thua những trận mà layer, importance, và thứ tự nguồn quyết định. Kỹ sư senior cần toàn bộ stack — không phải cheat sheet từ 2012.


Thứ tự giải quyết Cascade (2025–2026)

Khi hai declaration xung đột trên cùng element và property, trình duyệt so sánh theo thứ tự này. Khác biệt đầu tiên thắng; các bước sau không bao giờ chạy:

StepFactorÝ nghĩa
1Origin & importanceUser agent → user → author; !important đảo author vs user
2Cascade layer@layer Thứ tự @layer; style không layer thắng mọi layer
3SpecificityBộ (a, b, c)
4Source orderRule cuối trong document thắng
┌─────────────────────────────────────────────────────────────┐
│  1. Origin + !important                                     │
│     author normal < author !important < inline < ...        │
├─────────────────────────────────────────────────────────────┤
│  2. @layer (normal declarations)                            │
│     layer A < layer B < unlayered                             │
├─────────────────────────────────────────────────────────────┤
│  3. Specificity (a, b, c) — compared lexicographically       │
├─────────────────────────────────────────────────────────────┤
│  4. Source order — later wins                                 │
└─────────────────────────────────────────────────────────────┘

Insight quan trọng: specificity là bước 3, không phải bước 1. Một utility (0, 1, 0) trong @layer sau thắng selector component (1, 2, 0) trong layer trước — không cần !important.


Demo trực tiếp

Dùng calculator để gõ selector bất kỳ và nhận bộ (a, b, c). Visualizer layer cho thấy ba rule với specificity khác nhau cạnh tranh trên một element — bật/tắt thứ tự layer và xem kết quả thay đổi.

Mở demo đầy đủ:


Specificity: Bộ (a, b, c)

Specificity là bộ đếm ba cột, không phải một số nguyên. So a trước, rồi b, rồi c:

ColumnCountsExamples
aID selectors#header, #main
bClass, attribute, pseudo-class.btn, [type="text"], :hover, :focus-visible
cType, pseudo-elementdiv, span, ::before, ::placeholder

Selector phổ quát *, combinator (>, +, ~), và wrapper negation của :where() không cộng gì.

Ví dụ có lời giải

/* (0, 0, 1) — one type */
li { }

/* (0, 1, 1) — one class + one type */
ul.menu li { }

/* (0, 2, 1) — two classes + one type */
.nav .item a { }

/* (1, 1, 1) — one ID + one class + one type */
#sidebar .nav a { }

/* (1, 2, 1) — from the demo */
#nav .item a:hover { }

Với #nav .item a:hover:

#nav        →  a = 1
.item       →  b = 1
a           →  c = 1
:hover      →  b = 1
─────────────────────
Total        (1, 2, 1)

Inline style và !important

Không thay đổi bộ tuple; chúng thay đổi bước cascade nào áp dụng:

MechanismHiệu ứng cascade
style="color: red"Origin inline author — thắng rule stylesheet thường
!important in stylesheetChuyển declaration sang bucket author important
!important inlineThắng !important trong stylesheet

Quy tắc production: coi !importantdấu hiệu thiết kế thất bại, không phải công cụ tinh chỉnh. Hãy dùng @layer trước.


Pseudo hàm: :is(), :not(), :where(), :has()

Selectors Level 4 thay đổi cách specificity hoạt động trong pseudo hàm. Đây là chỗ hầu hết senior dev vẫn bất ngờ.

:is():not() — max của các đối số

Pseudo lấy specificity cao nhất trong các đối số:

/* :is(.a, #b) → max((0,1,0), (1,0,0)) = (1,0,0)
   plus span → (1, 0, 1) */
:is(.a, #b) span { }

/* :not(.disabled) → specificity of .disabled = (0, 1, 0) */
button:not(.disabled) { }

Trước :is(), viết #nav .item a, #nav .item buttonnhân đôi phần prefix #nav .item. :is() cho phép gom nhóm mà không phình tuple quá worst case.

:where() — luôn bằng không

Mọi đối số trong :where() đóng góp (0, 0, 0), bất kể chúng trông specific thế nào:

/* :where(.x) → 0; .y → (0,1,0) → total (0, 1, 0) */
:where(.x) .y { }

/* Same structure, but :is() would be (1, 1, 0) */
:is(#sidebar) .y { }

Tại sao :where() xuất sắc cho thư viện: ship reset hoặc base style không bao giờ chặn override của consumer. Style link design-system :where(a) giữ ở (0,0,1) — một class từ app thắng.

/* Library reset — zero class specificity cost */
@layer ds.base {
  :where(a) {
    color: inherit;
    text-decoration: underline;
  }
}

/* App override — trivial */
@layer app.components {
  .prose-link { color: var(--accent); text-decoration: none; }
}

:has() — max của đối số (như :is())

:has() dùng cùng quy tắc max-argument như :is():

/* max((0,1,0), (0,0,1)) = (0,1,0) for the :has() part */
.card:has(.badge, img) { }

/* Parent selection — specificity includes :has() argument */
form:has(input:invalid) { border-color: var(--error); }
/* :has(input:invalid) → max of input:invalid = (0, 2, 1)
   plus form → (0, 2, 2) */

Cascade Layers (@layer)

@layer thêm một bước cascade mới giữa origin và specificity. Hãy coi layer là làn ưu tiên rõ ràng — không thay thế selector tốt, mà là guardrail cấu trúc.

Khai báo thứ tự layer

Khai báo đầu tiên tên layer đặt thứ tự toàn cục. Layer sau thắng layer trước với declaration thường:

@layer reset, base, components, utilities;
@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; }
}

@layer base {
  body { font-family: var(--font-mono); line-height: 1.5; }
  a { color: var(--accent); }
}

@layer components {
  .card { padding: 1rem; border: 1px solid var(--border); }
  .btn { background: var(--accent); color: var(--bg); }
}

@layer utilities {
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; }
  .text-center { text-align: center; }
}

Layer vs specificity — kịch bản demo

Xét ba rule trên cùng element:

@layer base, components, utilities;

@layer base {
  .widget { color: gray; }              /* (0, 1, 0) */
}

@layer components {
  #target.widget { color: blue; }       /* (1, 1, 0) — highest specificity */
}

@layer utilities {
  .highlight { color: red; }            /* (0, 1, 0) — lowest specificity */
}

Với thứ tự mặc định, utilities thắng → đỏ. Selector ID trong components không bao giờ được so specificity — bước layer đã quyết định rồi.

Style không layer

Mọi rule ngoài @layer thắng mọi declaration thường có layer, bất kể specificity:

@layer components {
  #app .btn { color: blue; }
}

/* This wins — unlayered */
#app .btn { color: amber; }

Cảnh báo: vô tình để rule mới không layer là footgun migration phổ biến. Thiết lập convention: mọi thứ vào layer, hoặc cố ý dùng một file “overrides” không layer.

Layer lồng nhau

Layer có thể lồng. Thứ tự trong được giải quyết trước, rồi block lồng tham gia thứ tự parent:

@layer framework, app;

@layer framework {
  @layer reset, components;

  @layer reset {
    :where(h1, h2, h3) { margin: 0; font-size: inherit; }
  }

  @layer components {
    .f-card { border-radius: 8px; }
  }
}

@layer app {
  @layer overrides;

  @layer overrides {
    .f-card { border-radius: 0; }  /* beats framework.components — app > framework */
  }
}

Import vào layer

Tách CSS qua nhiều file mà không mất gán layer:

@layer reset, base, components, utilities;

@import url("reset.css") layer(reset);
@import url("tokens.css") layer(base);
@import url("components.css") layer(components);
@import url("utilities.css") layer(utilities);

Mỗi file import tự bọc rule trong layer đã đặt tên. Đây là cách design system lớn ship một CSS bundle với hành vi override dự đoán được.

!important đảo thứ tự ưu tiên layer

Với declaration important, layer trước thắng layer sau — ngược với rule thường:

@layer base, utilities;

@layer base {
  .text { color: gray !important; }    /* WINS over utilities !important */
}

@layer utilities {
  .text { color: red !important; }
}

Đảo ngược này tồn tại để layer ưu tiên thấp vẫn thể hiện reset cứng bằng !important mà utilities không mất lợi thế cascade thường.

Declaration typeLayer priority
NormalLayer sau thắng
!importantLayer trước thắng

Mẫu kiến trúc thực tế

Mẫu 1: Stack layer design-system

@layer ds.reset, ds.tokens, ds.components, app.base, app.features, app.utilities;
LayerOwnsSpecificity budget
ds.reset:where() resets, box-sizing(0, 0, 0)(0, 0, 1)
ds.components.ds-btn, .ds-card(0, 1, 0)
app.featuresPage-specific blocks(0, 1, 0)(0, 2, 0)
app.utilitiesTailwind-like helpers(0, 1, 0)

Team app không bao giờ đánh nhau với design system về specificity — họ tranh ở vị trí layer, rõ ràng và grep được.

Mẫu 2: :where() cô lập third-party

Khi bọc widget vendor, scope style bằng :where() để class app host luôn thắng trong cùng layer:

@layer vendor {
  :where(.vendor-root) :where(button) {
    font: inherit;
    border: 1px solid var(--border);
  }
}

Mẫu 3: Thuần hóa CSS legacy

Migrate codebase 50k dòng không big bang:

@layer legacy, app;

/* Move existing stylesheet wholesale */
@import url("legacy.css") layer(legacy);

@layer app {
  /* New code — always wins over legacy */
  .header { display: flex; }
}

Không rewrite selector ngày đầu. Code mới trong layer app thắng về cấu trúc. Refactor selector legacy dần dần.

Mẫu 4: Tránh cuộc đua vũ trang specificity

SmellFix
#page #content .sidebar .nav li a.activeFlatten to .nav-link--active in a higher layer
Chains of !importantIntroduce @layer utilities
ID selectors for stylingReplace with classes; IDs for JS hooks only
Duplicated long selectorsUse :is() to group, not repeat prefixes

Takeaway cấp Principal: mục tiêu không phải “specificity thấp mọi nơi”. mục tiêu là đường override dự đoán được — layer cho cấu trúc, :where() cho default không tốn specificity, class cho hook có chủ đích.


Debug xung đột Cascade

Khi style “lẽ ra” áp dụng nhưng không, duyệt bốn bước trong DevTools:

  1. Styles panel → kiểm tra property bị gạch ngang
  2. Click rule thắng → đọc badge layer (Chrome 99+)
  3. So specificity trong tooltip
  4. Cuộn xem rule sau cùng layer + specificity có thắng không
/* Quick isolation trick — temporary unlayered probe */
/* DELETE BEFORE COMMIT */
.debug-probe { outline: 2px solid red !important; }

Nếu probe chạy nhưng rule layered không, bạn có bug thứ tự layer, không phải bug specificity.


Hỗ trợ trình duyệt (2026)

FeatureChromeFirefoxSafariNotes
@layer99+97+15.4+Production-ready
:is() / :where()88+78+14+Baseline
:has()105+121+15.4+Baseline 2023
:not() max-specificity88+82+14+Level 4 behavior

Mọi tính năng trong bài an toàn cho app production hiện đại năm 2026. Với IE legacy, không áp dụng — nhưng bạn đã biết.


Tóm tắt

Cascade là cây quyết định bốn bước: origin/importance → layer → specificity → thứ tự nguồn. Specificity là bộ (a, b, c) — ID, rồi class/attribute/pseudo, rồi type/pseudo-element. :is(), :not(), :is(), :not(), và :has() lấy max đối số; :where() luôn bằng không.

@layer giúp kết thúc cuộc chiến specificity bằng kiến trúc, không phải đua vũ khí. Khai báo thứ tự một lần, import vào layer, giữ reset zero-cost với :where(), và không bao giờ để rule không layer vô tình trên production.

Dùng demo trên để xây trực giác trước thread Slack “sao style không apply?” lần tới.