concepts
cascade layers

Cascade Layers

CSS cascade layers refer to the order in which CSS rules are applied to an HTML element.

When multiple CSS rules apply to the same element, the browser uses the cascade to determine which rule should take precedence. See the MDN article (opens in a new tab) to learn more.

Bamboo takes advantage of the cascade to provide a more efficient and flexible way to organize styles. This allows you to define styles in a modular way, using CSS rules that are scoped to specific components or elements.

Layer Types

Bamboo's Vite compiler emits four cascade layers:

  • @layer reset - The reset layer is used to reset the default styles of HTML elements. This is used when preflight: true is set in the config. You can also use this layer to add your own reset styles.

The generated CSS for the reset layer looks like this:

@layer reset {
  * {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  /* ... */
}
  • @layer base - The base layer contains global styles defined in the global.css key in the config. You can also use this layer to add your own global styles.

The generated CSS for the base layer looks like this:

@layer base {
  a {
    color: #000;
    text-decoration: none;
  }
  /* ... */
}

Recipe declarations are resolved before CSS is materialized. Their declarations use the same global atoms as css() and therefore live in @layer utilities; no named recipe layer is emitted by Vite. When analyzable styles are composed, the compiler resolves later-wins precedence before allocating those atoms.

  • @layer tokens - The tokens layer contains css variables for tokens and semantic tokens. You can also use this layer to add your own design tokens.

The generated CSS for the tokens layer looks like this:

@layer tokens {
  :root {
    --color-primary: #000;
    --color-secondary: #fff;
    --color-tertiary: #ccc;
    --shadow-sm: 0 0 0 1px rgba(0, 0, 0, 0.05);
  }
  /* ... */
}
  • @layer utilities - Styles that are scoped to a specific utility class. These styles are only applied to elements that have the utility class applied.

Sublayers of utilities

Utility rules are not written into utilities directly. Each goes into a sublayer keyed by the rule's specificity, its condition and its property's priority, and the layer opens with a statement fixing their order:

@layer utilities {
  @layer s010-c0-p1000, s010-c0-p4000, s010-c2-p3000, s020-c1-p3000, important;
 
  @layer s010-c0-p1000 {
    .p_4 {
      padding: var(--spacing-4);
    }
  }
  @layer s010-c0-p4000 {
    .pt_2 {
      padding-top: var(--spacing-2);
    }
  }
  @layer s010-c2-p3000 {
    @media (width >= 48rem) {
      .md\:c_green\.500 {
        color: var(--colors-green-500);
      }
    }
  }
  @layer s020-c1-p3000 {
    .hover\:c_blue\.500:is(:hover, [data-hover]) {
      color: var(--colors-blue-500);
    }
  }
  @layer important {
    .c_red\.500\! {
      color: var(--colors-red-500) !important;
    }
  }
}

A name reads as its key. s010 is the specificity of the rule's selector, (0,1,0); c2 is the rule's condition, numbered in the order Bamboo sorts conditions, the same number wherever that condition appears; p3000 is the property's priority, shorthands before longhands. Every !important declaration goes into the one sublayer named important, because importance inverts layer order and one layer is the only place two important declarations still resolve by specificity and then source order.

This fixes precedence between sublayers regardless of where their rules sit in the file. One flat layer carried precedence in its source order — shorthands before longhands, bare rules before conditional ones, each breakpoint in its place — and source order is the one thing a stylesheet split per chunk cannot promise, since chunks load in whichever order a route needs them. Layer order is decided by the statement, whatever order the rules arrive in. That is what lets the Vite plugin give each lazily loaded chunk a sheet of its own: every chunk sheet repeats the statement, so whichever the document parses first establishes the same order.

Nothing resolves differently. Specificity comes first in the key because layer order outranks it: two rules of different specificity used to resolve by specificity alone, and now the more specific one sits in the later sublayer. Two rules of equal specificity used to resolve by source order, and between them the sublayers follow the sorter's order exactly. A test in packages/core models the browser's cascade over a corpus that exercises every way two declarations can compete and pins the winner of every pair; the sublayers reproduce that ranking without exception.

Layer Order

The cascade layers are applied in the following order:

  • @layer utilities (Highest priority)
  • @layer tokens
  • @layer base
  • @layer reset (Lowest priority)

Recipe and utility styles share this layer under Vite. Use cx(recipe(), css()) or compose style objects in the desired order so the compiler can remove overridden declarations before CSS is emitted.

Inside a layer, specificity still decides

Two css() calls share the top-level utilities layer, but their rules can occupy different sublayers. For normal utility declarations, Bamboo places a more specific selector in a later sublayer, preserving its priority. Within the same sublayer, specificity still decides and source order breaks ties. Class-name order on an element does not change that ranking.

const prose = css({
  '& p': { fontSize: '14.5px' }, //  (0,1,1) — a class and an element
})
 
const lede = css({ fontSize: '16px' }) //  (0,1,0) — a class
 
<article className={prose}>
  <p className={lede}>This renders at 14.5px.</p>
</article>

The paragraph carries lede directly and still loses, because & p is the more specific selector. Neither call knows about the other, and nothing in the authoring experience suggests one css() can outrank another — the CSS is correct, the class is on the element, and the value is simply not the one applied.

A nested selector reaches further than it looks, too. '& a': { textDecorationLine: 'underline' } on an article underlines every anchor inside it, including ones in a navigation card that styles itself.

Scoping bounds what a selector can reach, and is worth doing at the point you reach for nesting rather than after a screenshot diff:

css({
  '& > p': { fontSize: '14.5px' }, // direct children, not paragraphs anywhere below
  '& :is(p, li) a': { textDecorationLine: 'underline' }, // anchors in body text, not every anchor in the subtree
})

It does not draw a component boundary, though — a card nested in that article that renders a <p> is still inside & :is(p, li) a — and it does not change the arithmetic: & > p is (0,1,1) too, so it still outranks a class on the paragraph it matches.

What removes the question is putting the styles on the element itself. That is the recommendation for nested styles generally, and this is why.

@bamboocss/eslint-plugin reports nested selectors that reach another element — all of them, scoped or not, since all of them outrank a class on what they match — for projects that want this enforced rather than remembered:

eslint.config.js

rules: {
  'bamboo/no-descendant-selectors': 'warn',
}

Layer CSS

The first line of the generated CSS declares the layer order:

@layer reset, base, tokens, utilities;

The named recipes layer is not emitted. Recipe selections compile into shared atoms in utilities, and the sublayers of utilities are declared by a statement inside it.

Customize layers

Bamboo lets you customize the cascade layers, so your project can coexist with other solutions. Learn more about customizing layers here.