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.
Why the Cascade Still Matters {Tại sao Cascade vẫn quan trọng}
Every stylesheet you ship is a competition {Mỗi stylesheet bạn ship là một cuộc thi}. Dozens — sometimes thousands — of rules target the same elements {Hàng chục — đôi khi hàng nghìn — rule cùng nhắm vào một phần tử}. The browser does not guess {Trình duyệt không đoán}; it runs a deterministic cascade algorithm {thuật toán cascade xác định} to pick exactly one winning declaration per property {để chọn đúng một declaration thắng cho mỗi property}.
If you only memorize “IDs beat classes” {Nếu bạn chỉ nhớ “ID thắng class”}, you will lose fights that layers, importance, and source order decide {bạn sẽ thua những trận mà layer, importance, và thứ tự nguồn quyết định}. Senior engineers need the full stack {Kỹ sư senior cần toàn bộ stack} — not a cheat sheet from 2012 {không phải cheat sheet từ 2012}.
The Cascade Resolution Order (2025–2026) {Thứ tự giải quyết Cascade (2025–2026)}
When two declarations conflict on the same element and property {Khi hai declaration xung đột trên cùng element và property}, the browser compares them in this order {trình duyệt so sánh theo thứ tự này}. The first difference wins {Khác biệt đầu tiên thắng}; later steps are never reached {các bước sau không bao giờ chạy}:
| Step | Factor | What it means {Ý nghĩa} |
|---|---|---|
| 1 | Origin & importance | User agent → user → author; !important flips author vs user {User agent → user → author; !important đảo author vs user} |
| 2 | Cascade layer | @layer order; unlayered styles beat all layers {Thứ tự @layer; style không layer thắng mọi layer} |
| 3 | Specificity | The (a, b, c) tuple {Bộ (a, b, c)} |
| 4 | Source order | Last rule in document wins {Rule 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 │
└─────────────────────────────────────────────────────────────┘
Key insight {Insight quan trọng}: specificity is step 3, not step 1 {specificity là bước 3, không phải bước 1}. A
(0, 1, 0)utility in a later@layerbeats a(1, 2, 0)component selector in an earlier layer {Một utility(0, 1, 0)trong@layersau thắng selector component(1, 2, 0)trong layer trước} — without!important{không cần!important}.
Live Demo {Demo trực tiếp}
Use the calculator to type any selector and get its (a, b, c) tuple {Dùng calculator để gõ selector bất kỳ và nhận bộ (a, b, c)}. The layer visualizer shows three rules with different specificity competing on one element {Visualizer layer cho thấy ba rule với specificity khác nhau cạnh tranh trên một element} — toggle layer order and watch the winner change {bật/tắt thứ tự layer và xem kết quả thay đổi}.
Open the full demo {Mở demo đầy đủ}: /tools/css-specificity-demo/.
Specificity: The (a, b, c) Tuple {Specificity: Bộ (a, b, c)}
Specificity is a three-column counter {Specificity là bộ đếm ba cột}, not a single integer {không phải một số nguyên}. Compare a first, then b, then c {So a trước, rồi b, rồi c}:
| Column | Counts | Examples |
|---|---|---|
| a | ID selectors | #header, #main |
| b | Class, attribute, pseudo-class | .btn, [type="text"], :hover, :focus-visible |
| c | Type, pseudo-element | div, span, ::before, ::placeholder |
Universal selector *, combinators (>, +, ~), and the negation pseudo wrapper for :where() add nothing {Selector phổ quát *, combinator (>, +, ~), và wrapper negation của :where() không cộng gì}.
Worked examples {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 { }
For #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 styles and !important {Inline style và !important}
These do not change the tuple {Không thay đổi bộ tuple}; they change which cascade step applies {chúng thay đổi bước cascade nào áp dụng}:
| Mechanism | Cascade effect {Hiệu ứng cascade} |
|---|---|
style="color: red" | Author inline origin — beats normal stylesheet rules {Origin inline author — thắng rule stylesheet thường} |
!important in stylesheet | Moves declaration to important author bucket {Chuyển declaration sang bucket author important} |
!important inline | Beats !important in stylesheets {Thắng !important trong stylesheet} |
Production rule {Quy tắc production}: treat
!importantas a design failure signal {coi!importantlà dấu hiệu thiết kế thất bại}, not a precision tool {không phải công cụ tinh chỉnh}. Reach for@layerfirst {Hãy dùng@layertrước}.
Functional Pseudos: :is(), :not(), :where(), :has() {Pseudo hàm: :is(), :not(), :where(), :has()}
Selectors Level 4 changed how specificity works inside functional pseudos {Selectors Level 4 thay đổi cách specificity hoạt động trong pseudo hàm}. This is where most senior devs still get surprised {Đây là chỗ hầu hết senior dev vẫn bất ngờ}.
:is() and :not() — max of arguments {:is() và :not() — max của các đối số}
The pseudo takes the highest specificity among its arguments {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) { }
Before :is() {Trước :is()}, you’d write #nav .item a, #nav .item button and duplicate the #nav .item prefix specificity {viết #nav .item a, #nav .item button và nhân đôi phần prefix #nav .item}. :is() lets you group without inflating the tuple beyond the worst case {:is() cho phép gom nhóm mà không phình tuple quá worst case}.
:where() — always zero {:where() — luôn bằng không}
Every argument inside :where() contributes (0, 0, 0) {Mọi đối số trong :where() đóng góp (0, 0, 0)}, regardless of how specific they look {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 { }
Why
:where()is brilliant for libraries {Tại sao:where()xuất sắc cho thư viện}: ship reset or base styles that never block consumer overrides {ship reset hoặc base style không bao giờ chặn override của consumer}. A design-system link style:where(a)stays at(0,0,1){Style link design-system:where(a)giữ ở(0,0,1)} — one class from the app wins {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 of arguments (like :is()) {:has() — max của đối số (như :is())}
:has() uses the same max-argument rule as :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) {Cascade Layers (@layer)}
@layer adds a new cascade step between origin and specificity {@layer thêm một bước cascade mới giữa origin và specificity}. Think of layers as explicit priority lanes {Hãy coi layer là làn ưu tiên rõ ràng} — not a replacement for good selectors {không thay thế selector tốt}, but a structural guardrail {mà là guardrail cấu trúc}.
Declaring layer order {Khai báo thứ tự layer}
The first declaration of layer names sets global order {Khai báo đầu tiên tên layer đặt thứ tự toàn cục}. Later layers win over earlier ones for normal declarations {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 — the demo scenario {Layer vs specificity — kịch bản demo}
Consider three rules on the same element {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 */
}
With default order {Với thứ tự mặc định}, utilities wins → red {utilities thắng → đỏ}. The ID selector in components never gets a chance to compete on specificity {Selector ID trong components không bao giờ được so specificity} — the layer step already decided {bước layer đã quyết định rồi}.
Unlayered styles {Style không layer}
Any rule outside @layer beats all layered normal declarations {Mọi rule ngoài @layer thắng mọi declaration thường có layer}, regardless of specificity {bất kể specificity}:
@layer components {
#app .btn { color: blue; }
}
/* This wins — unlayered */
#app .btn { color: amber; }
Warning {Cảnh báo}: accidentally leaving new rules unlayered is a common migration footgun {vô tình để rule mới không layer là footgun migration phổ biến}. Establish a convention: everything goes in a layer, or use a single unlayered “overrides” file intentionally {Thiết lập convention: mọi thứ vào layer, hoặc cố ý dùng một file “overrides” không layer}.
Nested layers {Layer lồng nhau}
Layers can nest {Layer có thể lồng}. Inner order is resolved first, then the nested block participates in the parent order {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 */
}
}
Importing into layers {Import vào layer}
Split CSS across files without losing layer assignment {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);
Each imported file wraps its rules in the named layer automatically {Mỗi file import tự bọc rule trong layer đã đặt tên}. This is how large design systems ship one bundled CSS with predictable override behavior {Đây là cách design system lớn ship một CSS bundle với hành vi override dự đoán được}.
!important reverses layer priority {!important đảo thứ tự ưu tiên layer}
For important declarations, earlier layers beat later ones {Với declaration important, layer trước thắng layer sau} — the opposite of normal rules {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; }
}
This inversion exists so low-priority layers can still express hard resets with !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} without utilities losing their normal cascade advantage {mà utilities không mất lợi thế cascade thường}.
| Declaration type | Layer priority |
|---|---|
| Normal | Later layer wins {Layer sau thắng} |
!important | Earlier layer wins {Layer trước thắng} |
Practical Architecture Patterns {Mẫu kiến trúc thực tế}
Pattern 1: Design-system layer stack {Mẫu 1: Stack layer design-system}
@layer ds.reset, ds.tokens, ds.components, app.base, app.features, app.utilities;
| Layer | Owns | Specificity budget |
|---|---|---|
ds.reset | :where() resets, box-sizing | (0, 0, 0)–(0, 0, 1) |
ds.components | .ds-btn, .ds-card | (0, 1, 0) |
app.features | Page-specific blocks | (0, 1, 0)–(0, 2, 0) |
app.utilities | Tailwind-like helpers | (0, 1, 0) |
The app team never fights the design system on specificity {Team app không bao giờ đánh nhau với design system về specificity} — they fight on layer placement {họ tranh ở vị trí layer}, which is explicit and grep-able {rõ ràng và grep được}.
Pattern 2: :where() for third-party isolation {Mẫu 2: :where() cô lập third-party}
When wrapping a vendor widget {Khi bọc widget vendor}, scope styles with :where() so host app classes always win within the same layer {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);
}
}
Pattern 3: Taming legacy CSS {Mẫu 3: Thuần hóa CSS legacy}
Migrating a 50k-line codebase without a big bang {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; }
}
No selector rewriting on day one {Không rewrite selector ngày đầu}. New code in app layer wins structurally {Code mới trong layer app thắng về cấu trúc}. Refactor legacy selectors incrementally {Refactor selector legacy dần dần}.
Pattern 4: Avoid the specificity arms race {Mẫu 4: Tránh cuộc đua vũ trang specificity}
| Smell | Fix |
|---|---|
#page #content .sidebar .nav li a.active | Flatten to .nav-link--active in a higher layer |
Chains of !important | Introduce @layer utilities |
| ID selectors for styling | Replace with classes; IDs for JS hooks only |
| Duplicated long selectors | Use :is() to group, not repeat prefixes |
Principal-level takeaway {Takeaway cấp Principal}: the goal is not “low specificity everywhere” {mục tiêu không phải “specificity thấp mọi nơi”}. The goal is predictable override paths {mục tiêu là đường override dự đoán được} — layers for structure,
:where()for zero-cost defaults, classes for intentional hooks {layer cho cấu trúc,:where()cho default không tốn specificity, class cho hook có chủ đích}.
Debugging Cascade Conflicts {Debug xung đột Cascade}
When a style “should” apply but doesn’t {Khi style “lẽ ra” áp dụng nhưng không}, walk the four steps in DevTools {duyệt bốn bước trong DevTools}:
- Styles panel → check if property is struck through {Styles panel → kiểm tra property bị gạch ngang}
- Click the winning rule → read layer badge (Chrome 99+) {Click rule thắng → đọc badge layer (Chrome 99+)}
- Compare specificity in the tooltip {So specificity trong tooltip}
- Scroll to see if a later rule with equal layer + specificity wins {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; }
If the probe works but your layered rule doesn’t {Nếu probe chạy nhưng rule layered không}, you have a layer ordering bug, not a specificity bug {bạn có bug thứ tự layer, không phải bug specificity}.
Browser Support (2026) {Hỗ trợ trình duyệt (2026)}
| Feature | Chrome | Firefox | Safari | Notes |
|---|---|---|---|---|
@layer | 99+ | 97+ | 15.4+ | Production-ready |
:is() / :where() | 88+ | 78+ | 14+ | Baseline |
:has() | 105+ | 121+ | 15.4+ | Baseline 2023 |
:not() max-specificity | 88+ | 82+ | 14+ | Level 4 behavior |
All features in this post are safe for modern production apps in 2026 {Mọi tính năng trong bài an toàn cho app production hiện đại năm 2026}. For legacy IE support, none of this applies — but you already knew that {Với IE legacy, không áp dụng — nhưng bạn đã biết}.
Summary {Tóm tắt}
The cascade is a four-step decision tree {Cascade là cây quyết định bốn bước}: origin/importance → layers → specificity → source order {origin/importance → layer → specificity → thứ tự nguồn}. Specificity is the (a, b, c) tuple — IDs, then classes/attributes/pseudos, then types/pseudo-elements {Specificity là bộ (a, b, c) — ID, rồi class/attribute/pseudo, rồi type/pseudo-element}. :is(), :not(), and :has() take the max argument; :where() always contributes zero {:is(), :not(), và :has() lấy max đối số; :where() luôn bằng không}.
@layer lets you end specificity wars with architecture, not arms races {@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í}. Declare order once, import into layers, keep resets at zero cost with :where(), and never leave accidental unlayered rules in production {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}.
Use the demo above to build intuition before your next “why won’t this style apply?” Slack thread {Dùng demo trên để xây trực giác trước thread Slack “sao style không apply?” lần tới}.