Migrating an !important-Heavy Stylesheet to Cascade Layers

A stylesheet where every third rule ends in !important is not badly written — it is a codebase that never had a priority mechanism other than the flag. Each !important was added to win one specific fight, and the next author added another to win the fight against that. This guide replaces the whole escalation with an ordered @layer stack, so priority comes from where a rule lives, not from a flag stapled to every declaration. It builds on the role of !important in layers.

Prerequisites

Before you start, be comfortable with two facts from the role of !important in layers guide:

  • For normal declarations, a later-declared layer beats an earlier one regardless of selector specificity.
  • For !important declarations, that order inverts: an !important rule in an earlier-declared layer beats an !important rule in a later one.

That inversion is the whole reason a layered architecture lets you delete flags: once the priority you wanted lives in the layer order, the flag becomes redundant. You will also need Node.js ≥ 18 and either ripgrep or postcss for the inventory step.

Why the flag has to go before layers can win

The trap in an !important-heavy sheet is that layers do not rescue you automatically. A normal rule in your shiny new components layer loses to an !important rule anywhere — including one buried in an old file. So migration is not “add layers and stop.” It is “add layers, then systematically remove the flags whose job the layer order has taken over.” The diagram traces what happens to a single property as a rule moves out of the flagged legacy pile and into an ordered layer.

From !important escalation to layer order Left column shows two rules both using !important fighting by source order. Right column shows the same rules split into components and overrides layers, winning by layer position with no flag. Before: flag escalation .btn { color: red !important } legacy file, line 40 .btn { color: blue !important } legacy file, line 900 — wins by source order winner decided by whichever line is last fragile: reorder files, winner flips After: layer order @layer components { .btn { color: red } } no flag @layer overrides { .btn { color: blue } } later layer wins — no flag stable: winner set by the manifest

Step-by-step procedure

Step 1 — Inventory every !important

You cannot migrate what you cannot see. Produce a flat report of every flag before touching a rule.

# WHY: a raw count and per-file breakdown becomes your burndown metric.
# The number must trend to zero; track it in every migration PR.
rg --no-heading -n '!important' 'src/**/*.css' | tee important-report.txt
rg -c '!important' src/**/*.css | sort -t: -k2 -nr   # worst files first

What this does: Gives you a prioritised worklist and a baseline number. A file with 80 flags is a bigger slice than one with 3; migrate the concentrated files first because each yields the most cleanup per pass.

Step 2 — Declare the canonical layer order

Put one @layer statement at the very top of the entry stylesheet. This is the contract every later step relies on; nothing after it can reorder these layers.

/* entry.css — first stylesheet the browser parses */

/* WHY: declaring all layer names here fixes precedence up front.
   'legacy' sits LAST so untouched old rules keep winning until you
   migrate them; it is deleted once the migration completes. */
@layer reset, base, themes, components, utilities, overrides, legacy;

What this does: Registers layer precedence at parse time. From now on, a normal rule in overrides beats a normal rule in components, and both beat anything you leave in legacy — no flag involved. See understanding layer declaration order for why first-seen order is the source of truth.

Step 3 — Sandbox the untouched legacy CSS

Do not edit the old file yet. Import it whole into the legacy layer so the app looks identical on day one.

/* entry.css, immediately after the @layer statement */

/* WHY: layer() on @import wraps every rule in the file inside
   @layer legacy without changing a single line of that file.
   @import with layer() must come before any style rule. */
@import url("legacy.css") layer(legacy);

What this does: Every legacy rule — flags and all — is now confined to the last-declared layer. Because legacy is last, its normal rules still win over your empty new layers, preserving parity. But its !important rules now lose to !important in any earlier layer, which is exactly the lever you will pull in migration.

Step 4 — Migrate one slice at a time

Pick one feature (the button, the nav, the modal). Move its rules out of legacy.css and into the correct layer, deleting the flag as you go.

/* BEFORE (inside legacy.css):
   .btn--primary { background: #0055ff !important; color: #fff !important; } */

@layer components {
  /* WHY: the flag is gone. components is declared before legacy, so this
     normal rule already beats any NORMAL leftover in legacy. The only
     rules that could still beat it are !important ones — which Step 6
     verifies are gone for this property. */
  .btn--primary { background: var(--color-primary, #0055ff); color: #fff; }
}

What this does: Converts a flagged rule into a plain one whose priority comes from components sitting above legacy. Migrate the whole slice at once — every rule that touches .btn--primary — so you never leave one flagged rule fighting one un-flagged rule for the same property.

Step 5 — Flatten specificity with :where()

If the original selector was heavy (IDs, long descendant chains) and you cannot yet rename it in the HTML, wrap it in :where() so specificity stops mattering and layer order is the only axis left.

@layer components {
  /* WHY: :where() forces specificity to 0-0-0. The old selector
     #app .toolbar .btn.is-active used to need !important to win;
     now layer order wins and the selector weight is irrelevant. */
  :where(#app .toolbar .btn.is-active) {
    background: var(--color-primary, #0055ff);
  }
}

What this does: Removes the last reason the flag existed. The rule kept its !important only because a higher-specificity legacy selector was beating it; with specificity flattened to zero and the rule promoted to components, layer order alone decides the winner. The dedicated flattening specificity with :where() guide covers the pitfalls of over-applying this.

Step 6 — Verify the slice caused no regression

Before opening the PR, confirm three things for the migrated feature.

# WHY: the flag count for this feature's files must have dropped.
# If it did not, a flag was copied, not removed.
rg -c '!important' src/components/button.css   # expect 0

What this does: Proves the flags actually left the codebase rather than moving. Combine it with the DevTools trace in the next section and a screenshot diff to catch any visual shift.

Step 7 — Retire the legacy layer

When legacy.css is empty, delete its import and drop legacy from the manifest.

/* entry.css — final state, legacy removed */
@layer reset, base, themes, components, utilities, overrides;

What this does: Returns the stack to the canonical six-layer order with zero !important flags and zero legacy sandbox. The migration is complete when this line and a global rg -c '!important' of 0 both hold.

Verification

Static counts prove flags are gone; DevTools proves the cascade is right.

  1. Open Chrome DevTools → Elements → Styles. Migrated declarations are grouped under their @layer heading; a leftover legacy rule appears under a legacy group.
  2. Switch to the Computed pane and expand the property (e.g. background). The winning declaration shows its layer name in brackets, such as [components]. If you still see [legacy] winning, the slice was not fully migrated.
  3. Run a visual regression pass (Playwright, Percy, or Chromatic) after each slice. A misassigned layer surfaces as a pixel diff before merge.
  4. Keep the rg -c '!important' total in the PR description as a burndown number so reviewers can see it fall.

Troubleshooting

An !important in legacy still wins after migration. This is the inversion trap. !important reverses layer order, so an !important in legacy (declared last) loses to an !important in reset (declared first) — but it beats a normal rule in components. The fix is to remove !important from both the legacy rule and any overriding rule for that property, then let plain layer order resolve them. Never try to out-flag it from a later layer; you are feeding the escalation you are trying to end. The using !important to override a utility layer guide shows the one case where a flag is still legitimate.

An unlayered stylesheet keeps overriding everything. Unlayered author styles beat every named layer. If a <style> block, a runtime CSS-in-JS injection, or a <link> without a layer() annotation ships un-layered rules, they sit above your whole stack. Audit every entry point and route each into a layer via @import ... layer() or by wrapping the block in @layer.

Removing a flag changed a value you did not expect. The flag was hiding a second competing rule. Its disappearance is the flag doing its last useful thing: exposing a real conflict. Trace both declarations in the Computed pane, decide which layer each belongs in, and resolve them by order instead of by suppression.

Two features shared a selector and one slice broke the other. A selector like .card .btn couples the card slice to the button slice. Migrating one moves the rule out from under the other. Split shared selectors into per-feature rules before migrating, or migrate both coupled features in the same slice.

A third-party !important you cannot edit is winning. Import the library into an early layer (vendor, declared first). Override the specific property with a plain declaration in any later layer — a normal rule beats a normal vendor rule by layer order, and you avoid answering their flag with one of your own. Only if the vendor rule itself uses !important do you need !important in a later layer to beat it.

Complete working example

A single self-contained entry stylesheet showing the migration mid-flight: legacy sandboxed, two slices already promoted and de-flagged, the rest still winning from legacy.

/* ============================================================
   entry.css — load before every other stylesheet
   ============================================================ */

/* Canonical order; legacy is temporary and sits last. */
@layer reset, base, themes, components, utilities, overrides, legacy;

/* Untouched old file, fully sandboxed. Its NORMAL rules still win over
   empty new layers; its !important rules now lose to earlier layers. */
@import url("legacy.css") layer(legacy);

@layer reset {
  /* WHY: box-sizing early prevents legacy padding from shifting layout
     as rules move between layers during migration. */
  *, *::before, *::after { box-sizing: border-box; }
}

@layer themes {
  :root {
    /* WHY: centralised tokens replace the hardcoded colours that the
       old !important rules pinned in place. */
    --color-primary: #0055ff;
    --color-danger:  #d92d20;
  }
}

@layer components {
  /* MIGRATED SLICE 1 — was: .btn--primary { background:#0055ff !important } */
  .btn--primary {
    background: var(--color-primary);
    color: #fff;
    /* WHY: no flag. components beats legacy by order. */
  }

  /* MIGRATED SLICE 2 — heavy legacy selector kept, weight flattened */
  :where(#app .toolbar .btn.is-active) {
    /* WHY: :where() zeroes specificity so the promotion to components,
       not the old selector weight, decides the winner. */
    background: var(--color-primary);
  }
}

@layer overrides {
  /* WHY: a genuine page-level exception. It beats components by layer
     order — the job an !important used to do, now done structurally. */
  .checkout .btn--primary { inline-size: 100%; }
}

/* @layer legacy is populated only by the @import above. Every migration
   PR moves one slice out of legacy.css and drops its !important flags.
   When legacy.css is empty, delete the @import and remove 'legacy' here. */

FAQ

Do I have to remove every !important before layers help?

No. Layer order governs normal declarations immediately, so most flags can be removed as you migrate each rule into its correct layer. The exception is any remaining !important that must beat another !important — those interact through the inverted-priority rule and must be reasoned about together, not one flag at a time.

Why did removing an !important change a value I did not expect?

The flag was masking a second competing rule. Once removed, the previously suppressed declaration surfaces. This is a signal, not a setback: it reveals a real conflict the flag was hiding. Trace both declarations in DevTools, decide which layer each belongs in, and let layer order resolve them intentionally.

How do I handle !important inside a vendor file I cannot edit?

Import the vendor CSS into a low, early-declared layer such as vendor or legacy. Because !important inverts layer priority, an !important in an earlier layer loses to an !important in a later layer. Override the specific property with a plain declaration in any layer above the vendor one, or, only if the vendor rule itself is flagged, place your !important in a later layer like overrides.