Framework Integration: Wrapping Tailwind & Bootstrap in @layer
The day you add Bootstrap or Tailwind to a design system built on named layers, the framework’s stylesheet arrives as unlayered author CSS — and unlayered author styles beat every named layer you declared. Your carefully ordered components layer suddenly loses to a framework .btn it should override, and the reflex is another round of !important. This page, part of Architecture Patterns & Design System Scaling, shows how to slot each framework into the correct layer so its rules obey your cascade contract instead of overrunning it — covering Tailwind v4’s native layers, Bootstrap wrapping, and the ordering decisions that keep both predictable.
Concept & spec reference
The CSS Cascading and Inheritance specification places @layer order above selector specificity in the cascade. When two rules match the same element, the browser compares layer position first; specificity is only consulted when both rules live in the same layer. Framework integration is simply the application of that rule to code you did not write: if a framework’s stylesheet is assigned to a layer below your own, its high-specificity selectors can no longer beat your low-specificity ones.
There are two mechanisms for assigning a framework to a layer, and which you use depends on whether the framework emits its own @layer blocks:
/* Mechanism A — wrap a non-layered framework at import time.
Every rule inside bootstrap.css is placed into @layer vendor. */
@import url("bootstrap.css") layer(vendor);
/* Mechanism B — a framework that already emits @layer blocks
(Tailwind v4) is re-slotted by nesting its import under a
wrapper layer so its internal names don't collide with yours. */
@layer vendor {
@import "tailwindcss";
}The canonical stack this site uses gains one framework-facing layer at the bottom:
/* vendor sits first so any framework assigned to it loses to everything above */
@layer vendor, reset, base, themes, components, utilities, overrides;The layer() function on @import is the load-bearing detail. A plain @import url("bootstrap.css") brings Bootstrap in as unlayered author CSS, which sits above every named layer — the exact inversion of what you want. This is the same trap covered in depth under resolving third-party CSS conflicts.
How the browser resolves a wrapped framework
Walk through what happens when Bootstrap’s .btn and your design system’s .btn both match a <button class="btn">.
Step 1 — Layer registration. The browser reads @layer vendor, reset, base, themes, components, utilities, overrides; and registers seven layers in that order. vendor is index 0 (lowest priority).
Step 2 — Rule assignment. The @import url("bootstrap.css") layer(vendor) statement places Bootstrap’s .btn { ... } — a selector Bootstrap ships at specificity (0,1,0) — into vendor. Your @layer components { .btn { ... } } lands in components, index 4.
Step 3 — Cascade resolution. Both .btn rules match. The browser compares layers before specificity: components (index 4) outranks vendor (index 0), so your rule wins. Bootstrap’s identical specificity is never even compared, because the layer comparison already decided the outcome.
@layer vendor, reset, base, themes, components, utilities, overrides;
/* Bootstrap's .btn is now in vendor at (0,1,0) */
@import url("bootstrap.css") layer(vendor);
@layer components {
/* Wins over Bootstrap's .btn purely by layer order.
:where() keeps specificity at 0-0-0 so utilities and overrides
can still adjust this button without a specificity fight. */
:where(.btn) {
border-radius: var(--radius-md);
font-family: var(--font-body);
background: var(--color-primary);
}
}The mental model is worth stating plainly: once a framework is in a lower layer, its specificity stops mattering relative to yours. A framework can ship #root .navbar .btn.btn-primary at specificity (1,3,0) and it will still lose to your :where(.btn) at (0,0,0), because they are compared by layer first. This is exactly the guarantee that makes the base vs utility layer strategies decision tractable when a framework is in the mix.
Practical usage patterns
Pattern 1 — Tailwind v4 native @layer
Tailwind CSS v4 is layer-native. The single @import "tailwindcss" directive expands, roughly, to this:
/* What @import "tailwindcss" emits under the hood in v4 */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme); /* design tokens */
@import "tailwindcss/preflight.css" layer(base); /* the reset */
@import "tailwindcss/utilities.css" layer(utilities);Because Tailwind declares its own theme, base, components, and utilities layers, importing it unwrapped would interleave Tailwind’s layer names with yours in first-seen order — non-deterministic if your entry file and Tailwind disagree on ordering. The clean integration nests Tailwind’s whole output under a single wrapper layer you control:
/* Your canonical stack, with a dedicated slot for Tailwind's sub-layers */
@layer reset, base, themes, tw, components, utilities, overrides;
@layer tw {
/* Tailwind's internal theme/base/components/utilities become
tw.theme, tw.base, etc. — namespaced so they can never
reorder or shadow your top-level layers. */
@import "tailwindcss";
}If you specifically want Tailwind’s utility classes to override your components (the usual reason teams reach for Tailwind), assign Tailwind’s utilities to your top-level utilities layer instead of burying them in tw. The wrapping Tailwind and Bootstrap in cascade layers walkthrough gives the exact per-step configuration for both choices.
Pattern 2 — Bootstrap via @import layer(vendor)
Bootstrap 5 predates native cascade layers, so it emits ordinary unlayered rules. Wrap the whole distribution at import time:
@layer vendor, reset, base, themes, components, utilities, overrides;
/* The entire Bootstrap bundle drops into vendor with zero edits to
Bootstrap's own files. Its reset, grid, and components all land
in the lowest-priority layer together. */
@import url("bootstrap.css") layer(vendor);
@layer components {
/* Your button overrides Bootstrap's without !important */
:where(.btn) { border-radius: 0; }
}If you only need Bootstrap’s components and want its reset to live in your reset layer instead, split the imports by file — assign bootstrap-reboot.css to reset and the component bundle to vendor:
/* Bootstrap's Reboot goes into your reset layer so it participates
as a baseline, not an opaque vendor artefact. */
@import url("bootstrap-reboot.css") layer(reset);
@import url("bootstrap-grid.css") layer(vendor);
@import url("bootstrap-utilities.css") layer(utilities);Pattern 3 — The UI-kit sandwich
When you combine a component framework, your own components, and a utility framework, order them as a sandwich: framework resets and components on the bottom bun, your design system in the filling, utility classes and a sparse overrides layer on top.
/* The sandwich: vendor components lose to yours; utilities win over
yours by intent; overrides is the governed escape hatch on top. */
@layer vendor, reset, base, themes, components, utilities, overrides;
@import url("bootstrap.css") layer(vendor); /* bottom bun */
@layer components {
:where(.btn) { background: var(--color-primary); } /* filling */
}
@layer utilities {
/* Tailwind utilities (or your own) intentionally beat components,
so .p-0 flattens a Bootstrap-or-yours button on demand. */
.p-0 { padding: 0; }
}The sandwich is why a Tailwind utility can adjust spacing on a Bootstrap-derived component while your own component rules still override Bootstrap’s defaults — each collision resolves by declared layer position, never by which stylesheet the bundler happened to emit last.
Interaction with adjacent features
Framework wrapping does not stand alone; it leans on two neighbouring concepts.
Base vs utility layer strategies decides where a utility framework sits. Tailwind’s utilities above components means a utility class wins over a component default — the standard Tailwind expectation. Utilities below components means components win and utilities become suggestions. The framework you wrap does not change this axis; it only adds a vendor floor beneath it.
Resolving third-party CSS conflicts is the specificity-side view of the same problem. Wrapping a framework in vendor is the structural fix; that guide covers the residual cases — !important inside a framework, inline styles a framework injects via JavaScript, and Shadow DOM widgets a layer cannot reach.
One caution the interaction surfaces: !important inverts layer order. A framework rule marked !important in vendor beats a normal rule in components, and even beats an !important rule in a higher layer. Bootstrap’s .d-none { display: none !important } is the classic example — it keeps working precisely because of this inversion, which is intentional. When a framework’s !important fights you, the fix is an !important declaration in a lower layer than the framework, not a higher one.
DevTools & Stylelint diagnostic workflow
Confirming layer assignment in Chrome DevTools
- Open DevTools → Elements → Styles.
- Select an element the framework styles (a
.btn, a.navbar). - Chrome groups matched rules by layer. Confirm Bootstrap’s rules appear under
@layer vendor(or@layer twfor a wrapped Tailwind), not under(unlayered). - If any framework rule shows as
(unlayered), its@importis missing thelayer()annotation — or a rule appears above the@import, which voids the annotation. - Open Sources → Cascade layers (Chrome 107+) for the full layer tree, and verify
vendoris first in the list.
Catching unlayered framework imports with Stylelint
The most common regression is a teammate adding @import 'some-framework.css' without layer(). Enforce it in CI:
// stylelint.config.js
export default {
plugins: ["@csstools/stylelint-plugin-cascade-layers"],
rules: {
// Flags any selector authored outside a named @layer — including
// framework rules pulled in by a bare @import without layer().
"@csstools/cascade-layers/require-defined-layers": [
true,
{ layerOrder: ["vendor", "reset", "base", "themes", "components", "utilities", "overrides"] }
]
}
};Pair it with a build-time guard that greps for un-annotated framework imports before they reach production:
# WHY: any @import without a layer() annotation lands as unlayered
# author CSS and silently beats the whole stack. Fail the build on it.
grep -rnE "@import +url\([^)]*\); *$" src/styles && exit 1 || echo "all imports layered"Migration checklist
Follow these steps to move a framework-based stylesheet onto the layer stack:
- Declare the canonical layer order. Add
@layer vendor, reset, base, themes, components, utilities, overrides;as the first statement in your entry stylesheet, above every framework import. - Map each framework’s cascade footprint. For every framework, note which rules are reset/preflight, which are components, and which are utilities — each maps to a different target layer.
- Wrap non-layered frameworks with
@import layer(). Replace@import 'bootstrap.css'with@import url('bootstrap.css') layer(vendor)so its rules stop arriving unlayered. - Re-map native-layer frameworks. For Tailwind v4, nest
@import "tailwindcss"inside a wrapper layer, or route itsutilitiesto your top-levelutilitieslayer, so its layer names cannot collide with yours. - Delete
!importantescapes. Every!importantthat existed only to beat a framework selector can go once the framework sits in a lower layer. - Verify in DevTools and CI. Confirm each framework rule sits under its intended layer in the Styles panel, and run the cascade-layers Stylelint plugin so no unlayered framework import can regress.
Edge cases & gotchas
@import order voids layer()
The layer() annotation on @import only works if the @import appears before any style rule (a @charset or bare @layer declaration may precede it). Put a single .foo {} rule above your framework imports and the browser silently ignores layer(), dropping the framework back into unlayered CSS. Keep every @import at the very top of the entry file.
Bundlers that inline @import
Vite, webpack, and PostCSS’s postcss-import resolve and inline @import at build time. Most preserve the layer() annotation by wrapping the inlined content in an @layer block — but older postcss-import versions strip it. Verify the built artifact contains @layer vendor { ... } around the framework’s rules, not a bare inlined dump. If the annotation is lost, wrap the import manually: @layer vendor { @import "bootstrap.css"; }.
Tailwind v3 is not layer-native
Only Tailwind v4 emits real @layer blocks. Tailwind v3’s @tailwind base; @tailwind components; @tailwind utilities; directives produce unlayered CSS (its @layer at-rule there is a Tailwind-specific build construct, not the CSS cascade layer). A v3 project must be wrapped like Bootstrap — @import url("tailwind-built.css") layer(vendor) — or upgraded to v4 to get native integration.
Framework !important still bites
Bootstrap’s utility classes (.d-none, .text-center) ship !important. Because !important inverts layer order, those declarations beat your normal rules in higher layers by design. If you must override a framework !important, place your !important rule in a layer earlier than the framework’s — counterintuitive, but it is how the inversion resolves.
JavaScript-injected framework styles
Some widgets (date pickers, rich-text editors) inject a <style> tag at runtime. Those styles are unlayered and beat your whole stack. A cascade layer cannot reach them; scope them with a wrapper element and higher-specificity overrides in overrides, or load the widget’s CSS yourself via @import ... layer(vendor) and suppress its runtime injection.
FAQ
Does Tailwind CSS v4 use native cascade layers?
Yes. In v4 the single @import "tailwindcss" expands to a @layer theme, base, components, utilities; declaration plus layered sub-imports, so Tailwind’s own output already lives inside named layers. You integrate by re-slotting those layer names inside your own stack — for example nesting them under a tw wrapper layer — rather than importing Tailwind as unlayered CSS. Tailwind v3 predates native layers and must be wrapped with @import layer() or a build-time wrapper instead.
How do I stop Bootstrap from overriding my design-system components?
Import Bootstrap into a low-priority layer with @import url('bootstrap.css') layer(vendor) and declare vendor before your components layer in the canonical @layer vendor, reset, base, themes, components, utilities, overrides; statement. Because layer order beats specificity, any rule in components wins over Bootstrap’s .btn even though Bootstrap uses higher-specificity selectors — with no !important and no edits to Bootstrap’s files. See resolving third-party CSS conflicts for the residual !important cases.
Should a framework go above or below my component layer?
A full component framework like Bootstrap goes below your components layer so your own components win. A utility framework like Tailwind usually goes above components so utility classes can intentionally override component defaults. That is the UI-kit sandwich: framework base and reset at the bottom, your components in the middle, utility classes on top, and a sparse overrides layer above everything. The base vs utility layer strategies guide covers the trade-off in full.
Can I mix Tailwind utilities and Bootstrap components in the same project?
Yes, and cascade layers are what make it safe. Put Bootstrap in a vendor layer at the bottom, your components in components, and Tailwind’s utilities layer above components. The layer order guarantees a Tailwind utility can adjust spacing on a Bootstrap-derived component while your own component rules still override Bootstrap’s defaults — every collision resolves by declared layer position rather than by whichever stylesheet loaded last.
Related
- Wrapping Tailwind and Bootstrap in Cascade Layers — the step-by-step procedure with per-step config for both frameworks and a complete entry stylesheet
- Base vs Utility Layer Strategies — whether a utility framework should sit above or below your component layer
- Component Layer Isolation — how the
componentslayer stays leak-proof once a framework sits beneath it - Resolving Third-Party CSS Conflicts — the specificity-side view, including
!importantand inline-style edge cases