Fixing Bootstrap Conflicts Using @layer Overrides

Your .btn-primary override silently loses to Bootstrap every time — the fix is one @layer declaration that permanently lowers Bootstrap’s precedence, which is the core technique covered in Resolving Third-Party CSS Conflicts and grounded in the broader Specificity Management & Conflict Resolution approach.

Prerequisites

Before following these steps you should be comfortable with:

Tooling required: a build pipeline that emits a single CSS entry point (Vite, webpack, Parcel, or plain PostCSS). Bootstrap must be installed as a package (bootstrap on npm) so you can @import it; CDN <link> tags cannot be assigned to a layer from CSS directly.


The core problem: Bootstrap wins by document position, not by design

Without @layer, every Bootstrap rule and every custom rule compete in the same unnamed layer. Bootstrap ships after your reset but before your components in the typical setup, so a compound selector like .btn-primary:hover — specificity (0,2,0) — beats your single-class .btn-primary override — specificity (0,1,0) — even when your rule appears later in the file.

The diagram below shows how the cascade evaluates priority before specificity matters:

Cascade layer priority beats selector specificity A flow diagram showing the browser's cascade resolution order: first layer priority (framework loses to components), then within-layer specificity, then document order. Bootstrap's .btn-primary:hover is in the framework layer and loses to .btn-primary in the components layer despite having higher specificity. Step 1: Layer Priority framework < components Step 2: Specificity only within same layer Step 3: Document Order only within same layer @layer framework { .btn-primary:hover { … } } specificity (0,2,0) — but lower layer @layer components { .btn-primary { … } } specificity (0,1,0) — but HIGHER layer ✓ Components layer wins

Step-by-step procedure

Step 1 — Declare the layer stack

Add a single @layer statement at the very top of your CSS entry file — before any @import rules that contain rule blocks, and before any selector rules.

/* styles/main.css */

/* Declare layer order first — this single line fixes cascade precedence
   for every rule that follows, regardless of selector complexity. */
@layer reset, framework, theme, components, utilities;

What this does: The browser registers the five layer names in ascending priority order. Any rule later assigned to framework will lose to any rule in theme, components, or utilities — even if the framework rule has a more complex selector.

Step 2 — Import Bootstrap into the framework layer

/* Assign Bootstrap entirely to the framework layer.
   @import must appear before any rule blocks — place it right after
   the @layer declaration statement above. */
@import 'bootstrap/dist/css/bootstrap.min.css' layer(framework);

/* Your own reset lives below Bootstrap in the stack, but above
   user-agent defaults. */
@import './reset.css' layer(reset);

What this does: Every Bootstrap selector — including its compound pseudo-class patterns like .btn-primary:hover and .form-control:focus — is now sealed inside the framework layer. None of them can beat rules in theme, components, or utilities.

Step 3 — Override via CSS custom properties in the theme layer

Bootstrap 5 exposes its design decisions as --bs-* custom properties on :root. Reassigning them in the theme layer is the most targeted override strategy: one :root block cascades through every component that reads those variables.

@layer theme {
  :root {
    /* Remap Bootstrap's button token to your design system's primary.
       Bootstrap reads --bs-btn-bg at paint time, so this wins
       without touching any .btn selector. */
    --bs-btn-bg: var(--ds-color-primary);
    --bs-btn-border-color: var(--ds-color-primary-dark);
    --bs-btn-hover-bg: var(--ds-color-primary-dark);
    --bs-btn-color: var(--ds-color-on-primary);

    /* Typography tokens — Bootstrap's utilities read these */
    --bs-body-font-family: var(--ds-font-sans);
    --bs-body-font-size: var(--ds-text-base);
    --bs-link-color: var(--ds-color-accent);
  }
}

What this does: Because theme sits above framework in the layer stack, these :root assignments win over Bootstrap’s own :root block. The --bs-* values resolve to your design tokens before Bootstrap’s components paint.

Step 4 — Apply structural overrides in the components layer

Some properties — border-radius, font-weight, padding, box-shadow — are not controlled by Bootstrap’s custom properties and must be overridden with selector rules. Write those in @layer components.

@layer components {
  /* A single-class selector is enough here. The components layer
     outranks framework, so .btn beats Bootstrap's .btn-primary:hover
     without any specificity games. */
  .btn {
    border-radius: var(--ds-radius-md);
    font-weight: 500;
    letter-spacing: 0.01em;
  }

  /* Card surface overrides — no need to match Bootstrap's
     .card > .card-body specificity chain. */
  .card {
    border-radius: var(--ds-radius-lg);
    box-shadow: var(--ds-shadow-sm);
    border-color: var(--ds-color-border);
  }

  /* Form inputs */
  .form-control,
  .form-select {
    border-radius: var(--ds-radius-sm);
    border-color: var(--ds-color-border);
  }
}

What this does: Every rule here uses the minimum selector weight needed to identify the element. The layer stack handles precedence, so you never need to inflate specificity to beat Bootstrap.

Step 5 — Handle compound-selector edge cases with :where()

Bootstrap uses data attributes and compound selectors for theming and interactive states. When you need to match those patterns without adding specificity of your own, wrap the matching portion in :where().

@layer components {
  /* :where() zeroes out the specificity of everything inside it.
     The rule matches .navbar-dark .navbar-nav .nav-link but carries
     specificity (0,0,0) — the layer alone provides precedence. */
  :where(.navbar-dark) .navbar-nav .nav-link {
    color: var(--ds-color-text-inverse);
    opacity: 0.9;
  }

  /* Override Bootstrap's data-bs-theme attribute-driven dark palette */
  [data-bs-theme='dark'] .card {
    background-color: var(--ds-color-surface-elevated);
    color: var(--ds-color-text-primary);
  }
}

What this does: :where() lets you write a rich selector for matching precision while keeping specificity at (0,0,0). Combined with layer priority, the rule wins over Bootstrap’s equivalents without accidentally beating utility classes in your utilities layer.


Verification

After rebuilding, open DevTools on a Bootstrap component and check three things:

  1. @layer badges in the Styles pane. Every applied rule should show a layer badge (framework, theme, or components). Rules without a badge are unlayered and will automatically beat all named layers — investigate any you find.
  2. Crossed-out Bootstrap rules. Bootstrap’s .btn-primary and :hover rules should appear struck through in the Styles pane, overridden by your components layer entries.
  3. No stray !important. Run grep -c '!important' dist/styles.css in your build output. Legitimate !important use in a correctly structured layer stack should be near-zero.

Troubleshooting

My override still loses even after wrapping Bootstrap in a layer. : Check for unlayered rules in your own codebase. Any rule outside a declared @layer block sits in the implicit unlayered tier, which ranks above all named layers. Search your source for selectors not wrapped in @layer { } and assign them. See Debugging specificity leaks for the full diagnostic workflow.

Bootstrap’s !important utilities (.d-none, .text-center) are defeating my components layer. : This is correct browser behaviour: an !important declaration in a lower layer outranks a normal declaration in a higher layer — the !important priority ordering is inverted. Either use !important in your own higher layer rule, or — preferably — avoid targeting these utility classes with component overrides. The role of !important in layers covers the full priority matrix.

The @layer declaration is being hoisted or reordered by my bundler. : Vite and modern PostCSS preserve @layer order natively. If you are using a legacy minifier, switch to lightningcss or cssnano v6+, both of which treat @layer as order-sensitive. Verify post-build with: grep -n '@layer' dist/styles.css — the declaration line must appear before any rule blocks.

Bootstrap is loaded via a CDN <link> tag, not @import. : A <link> tag cannot be assigned to a layer from CSS. Two options: (a) switch to an @import url('https://cdn.../bootstrap.min.css') layer(framework) declaration in your CSS entry file, or (b) use PostCSS to emit a local proxy file that @imports the CDN URL inside a layer() wrapper at build time.

CSS custom property overrides in theme have no effect. : Bootstrap 5.2+ reads --bs-* custom properties at component level, not always at :root. Some components re-declare the property on the component element itself (e.g., .btn sets --bs-btn-bg locally). In that case override the property on the selector, not on :root: @layer theme { .btn { --bs-btn-bg: var(--ds-color-primary); } }.


Complete working example

Copy this self-contained entry stylesheet to get a working Bootstrap override setup:

/* ============================================================
   styles/main.css  —  Bootstrap @layer isolation setup
   ============================================================ */

/* 1. Declare layer order. This single line controls ALL precedence.
      Later layers win. Utilities is last = highest priority. */
@layer reset, framework, theme, components, utilities;

/* 2. Bootstrap goes into the framework layer.
      Every Bootstrap selector — no matter how complex — now loses
      to any rule in theme, components, or utilities. */
@import 'bootstrap/dist/css/bootstrap.min.css' layer(framework);

/* 3. Your reset. Could also use @import './reset.css' layer(reset) */
@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
  body { margin: 0; }
}

/* 4. Remap Bootstrap's design tokens to your design system.
      Affects every Bootstrap component that reads these variables. */
@layer theme {
  :root {
    --bs-btn-bg:           var(--ds-color-primary);
    --bs-btn-border-color: var(--ds-color-primary-dark);
    --bs-btn-hover-bg:     var(--ds-color-primary-dark);
    --bs-btn-color:        var(--ds-color-on-primary);
    --bs-body-font-family: var(--ds-font-sans);
    --bs-link-color:       var(--ds-color-accent);
  }
}

/* 5. Structural overrides that custom properties cannot reach.
      Single-class selectors are sufficient — no specificity games. */
@layer components {
  .btn {
    border-radius: var(--ds-radius-md);
    font-weight: 500;
  }

  .card {
    border-radius: var(--ds-radius-lg);
    box-shadow: var(--ds-shadow-sm);
    border-color: var(--ds-color-border);
  }

  .form-control,
  .form-select {
    border-radius: var(--ds-radius-sm);
    border-color: var(--ds-color-border);
  }

  /* Zero-specificity compound selector match via :where() */
  :where(.navbar-dark) .navbar-nav .nav-link {
    color: var(--ds-color-text-inverse);
  }
}

/* 6. Utility classes — highest priority, override everything above */
@layer utilities {
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
  }
}

Frequently Asked Questions

Does wrapping Bootstrap in @layer break its JavaScript plugins?

No. @layer only affects CSS cascade order; Bootstrap’s JS plugins manipulate the DOM and add classes that are still present and styled. The visual output changes only where your higher layers redefine those classes.

What if a Bootstrap rule uses !important inside the layer?

An !important declaration inside a lower-priority layer wins over a normal declaration in a higher-priority layer — the priority ordering for !important is reversed. You must counter it with !important in your own higher layer, or preferably override the CSS custom property the rule targets before it resolves. The role of !important in layers explains the full priority matrix.

Will @layer work if Bootstrap is loaded via a CDN <link> tag?

Not directly — you cannot assign a <link> tag to a layer from HTML alone. Switch to @import url('https://cdn.../bootstrap.min.css') layer(framework) in your CSS entry file, or have PostCSS emit a proxy file that wraps the CDN URL inside a layer() at build time.