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.

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}:

StepFactorWhat it means {Ý nghĩa}
1Origin & importanceUser agent → user → author; !important flips author vs user {User agent → user → author; !important đảo author vs user}
2Cascade layer@layer order; unlayered styles beat all layers {Thứ tự @layer; style không layer thắng mọi layer}
3SpecificityThe (a, b, c) tuple {Bộ (a, b, c)}
4Source orderLast 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 @layer beats a (1, 2, 0) component selector in an earlier layer {Một utility (0, 1, 0) trong @layer sau 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}:

ColumnCountsExamples
aID selectors#header, #main
bClass, attribute, pseudo-class.btn, [type="text"], :hover, :focus-visible
cType, pseudo-elementdiv, 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}:

MechanismCascade 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 stylesheetMoves declaration to important author bucket {Chuyển declaration sang bucket author important}
!important inlineBeats !important in stylesheets {Thắng !important trong stylesheet}

Production rule {Quy tắc production}: treat !important as a design failure signal {coi !importantdấ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 @layer first {Hãy dùng @layer trướ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():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 buttonnhâ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 typeLayer priority
NormalLater layer wins {Layer sau thắng}
!importantEarlier 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;
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)

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}

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

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}:

  1. Styles panel → check if property is struck through {Styles panel → kiểm tra property bị gạch ngang}
  2. Click the winning rule → read layer badge (Chrome 99+) {Click rule thắng → đọc badge layer (Chrome 99+)}
  3. Compare specificity in the tooltip {So specificity trong tooltip}
  4. 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)}

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

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}.