Finding Which Layer Won a Declaration

A padding is wrong, six rules match the element, and the Styles panel shows three of them struck through in an order that does not obviously explain the result. There is no getWinningLayer() to call — provenance is one of the few things the CSSOM deliberately does not expose. What exists instead are four inference techniques, each better suited to a different size of problem. This extends debugging specificity leaks in the specificity management reference.

Prerequisites

Know the sort order: origin and importance first, then layer, then specificity, then source order. The practical consequence is the first question to ask — are the competing rules in the same layer? If not, weight is irrelevant.

/* WHY the canonical stack here: every example below refers to positions in
   it, and a shared reference stack makes the reasoning transferable. */
@layer reset, base, theme, components, utilities;

Tooling: any modern DevTools. Techniques 3 and 4 are console-only and work identically in all three engines.

Technique 1: Read the Computed tab

Computed → filter "padding-block-start" → expand the disclosure triangle

  padding-block-start   16px
    ▸ .card              @layer components    components.css:24
      .card              @layer base          base.css:11     (struck through)

What this does: answers the question directly in most cases. The Computed tab resolves per property, which matters — the Styles panel’s strike-throughs describe rules, and a single rule frequently wins one property while losing another.

Why the Computed tab answers the question and the Styles panel often does not Two panels. The Styles panel lists three matching rules in cascade order with individual declarations struck through. The Computed tab lists one property with its resolved value and names the single rule and layer that produced it. Styles panel — per rule .card @layer utilities padding: 8px color: navy .card @layer components padding: 16px .card @layer base padding: 4px The top rule wins `color` but LOSES `padding`. Computed tab — per property padding-block-start 16px .card @layer components components.css:24 One property, one answer, one layer — which is the question you actually asked.

Technique 2: Substitute the suspected rule

When you have a hypothesis, test it by removal rather than by reading.

// WHY substitution: no API reports provenance, but the CONSEQUENCE of a rule
// is observable. If neutralising a declaration changes the computed value,
// that declaration was the winner.
const el = document.querySelector('.card');
const before = getComputedStyle(el).paddingBlockStart;

const sheet = [...document.styleSheets].find((s) => s.href?.includes('components'));
const rule = [...sheet.cssRules].find((r) => r.cssText?.startsWith('.card'));
const saved = rule.style.paddingBlockStart;

rule.style.paddingBlockStart = '';                       // neutralise
const after = getComputedStyle(el).paddingBlockStart;
rule.style.paddingBlockStart = saved;                    // restore

console.log({ before, after, wasWinner: before !== after });

What this does: gives a yes/no answer for one candidate in about ten seconds, and works identically in every engine. It is also the only technique that works when the rule lives inside a @media or @container block whose annotation is easy to misread.

Technique 3: Walk the CSSOM for every match

For a genuinely unfamiliar element, enumerate everything.

// WHY the layer path is threaded through the recursion: a nested layer's
// name is only meaningful with its parents, and `components.checkout` is a
// different answer from `components`.
function candidates(el, prop) {
  const out = [];
  const walk = (rules, path = []) => {
    for (const r of rules) {
      const kind = r.constructor.name;
      if (kind === 'CSSLayerBlockRule') {
        walk(r.cssRules, r.name ? [...path, r.name] : path);
      } else if (r.cssRules && kind !== 'CSSStyleRule') {
        walk(r.cssRules, path);              // @media, @supports, @container
      } else if (r.selectorText && r.style.getPropertyValue(prop)) {
        try {
          if (el.matches(r.selectorText)) {
            out.push({
              layer: path.join('.') || '(unlayered)',
              important: r.style.getPropertyPriority(prop) === 'important',
              value: r.style.getPropertyValue(prop),
              selector: r.selectorText,
            });
          }
        } catch { /* a selector this engine cannot evaluate — skip it */ }
      }
    }
  };
  for (const s of document.styleSheets) {
    try { walk(s.cssRules); } catch { /* cross-origin sheet */ }
  }
  return out;
}

console.table(candidates(document.querySelector('.card'), 'padding-block-start'));

What this does: produces the complete candidate list with layer and importance columns. Sort it mentally by the cascade order — importance, then layer position, then specificity — and the winner is the top row. The (unlayered) and important: true rows are worth checking first, because they short-circuit everything else.

Technique 4: Bisect by layer

When the candidate list is long, disable whole layers instead of individual rules.

// WHY bisecting by layer: it halves the search space per step and needs no
// hypothesis. Disabling a CSSLayerBlockRule is not directly supported, so
// this neutralises every declaration inside it and restores afterwards.
function muteLayer(name, prop) {
  const saved = [];
  const walk = (rules, inTarget = false) => {
    for (const r of rules) {
      if (r.constructor.name === 'CSSLayerBlockRule') {
        walk(r.cssRules, inTarget || r.name === name);
      } else if (r.cssRules) {
        walk(r.cssRules, inTarget);
      } else if (inTarget && r.style?.getPropertyValue(prop)) {
        saved.push([r, r.style.getPropertyValue(prop),
                    r.style.getPropertyPriority(prop)]);
        r.style.removeProperty(prop);
      }
    }
  };
  for (const s of document.styleSheets) {
    try { walk(s.cssRules); } catch { /* cross-origin */ }
  }
  return () => saved.forEach(([r, v, p]) => r.style.setProperty(prop, v, p));
}

const restore = muteLayer('components', 'padding-block-start');
console.log(getComputedStyle(document.querySelector('.card')).paddingBlockStart);
restore();

What this does: identifies the responsible layer without needing to know which rule inside it is responsible — usually enough to fix the problem, since the fix is normally “this belongs in a different layer”.

Choosing an attribution technique Four techniques ranked by effort. The Computed tab is instant and suits most cases. Rule substitution is quick and suits a specific hypothesis. The CSSOM walker enumerates all candidates for an unfamiliar element. Layer bisection suits a long candidate list or a generated stylesheet. technique effort reach for it when 1 · Computed tab always start here — it answers most cases outright 2 · rule substitution you have a specific suspect to confirm or clear 3 · CSSOM match walker the element is unfamiliar and you need every candidate 4 · bisect by layer the list is long, or the CSS is generated and unreadable

Verification

  1. Confirm the answer by removing the identified declaration and watching the computed value fall through to the next candidate.
  2. Check the runner-up is what you expect — if it is not, there is another rule you have not accounted for.
  3. Turn the finding into a test so nobody repeats the investigation:
// The card's padding must come from `components`, whatever its value is.
test('card padding resolves from the components layer', async ({ page }) => {
  await page.goto('/components/card.html');
  const v = await page.locator('.card').evaluate(
    (el) => getComputedStyle(el).paddingBlockStart
  );
  expect(v).toBe('16px');
});

Troubleshooting

The Computed tab names a rule but no layer. : The winning rule is unlayered. That is itself the finding — unlayered author styles outrank every layer, and the fix is to move the rule into one.

Four answers the investigation can end on Four possible conclusions: an author rule in a named layer won, an unlayered rule won, an inline style won, or no author rule is responsible and the value is inherited or a browser default. a rule in a named layer the normal case — the annotation names it an unlayered author rule it outranks every layer — move it into one an inline style attribute outside the layer system — change what writes it nothing in the author origin inherited from an ancestor, or a browser default

No rule appears at all for a property that clearly has a value. : The value is inherited from an ancestor rather than set on this element. Walk up the tree and repeat the investigation there.

The CSSOM walker throws on document.styleSheets. : A cross-origin stylesheet without CORS headers makes cssRules throw. The try/catch in the examples handles it; without one, the walk aborts at the first font provider.

A rule appears in the candidate list but is not applying. : It is inside a @media, @supports or @container block whose condition is false. The walker collects rules regardless of condition; the substitution technique in Technique 2 is condition-aware because it observes the actual computed value.

Two candidates in the same layer with identical specificity. : Source order decides — the later one wins. Inside a bundle that order is decided by the build, which is a good reason to keep related rules in one file.

Complete working example

// src/dev/who-won.js — one function that runs the whole investigation.
// WHY a single entry point: during a real debugging session, remembering
// four separate snippets is the bottleneck.
export function whoWon(selector, prop) {
  const el = document.querySelector(selector);
  if (!el) return { error: `no element matches ${selector}` };

  const resolved = getComputedStyle(el).getPropertyValue(prop);

  // 1 · enumerate every candidate with its layer path and importance
  const candidates = [];
  const walk = (rules, path = []) => {
    for (const r of rules) {
      const kind = r.constructor.name;
      if (kind === 'CSSLayerBlockRule') {
        walk(r.cssRules, r.name ? [...path, r.name] : path);
      } else if (r.cssRules && kind !== 'CSSStyleRule') {
        walk(r.cssRules, path);
      } else if (r.selectorText && r.style?.getPropertyValue(prop)) {
        try {
          if (el.matches(r.selectorText)) {
            candidates.push({
              layer: path.join('.') || '(unlayered)',
              important: r.style.getPropertyPriority(prop) === 'important',
              value: r.style.getPropertyValue(prop),
              selector: r.selectorText,
              _rule: r,
            });
          }
        } catch { /* unevaluatable selector in this engine */ }
      }
    }
  };
  for (const s of document.styleSheets) {
    try { walk(s.cssRules); } catch { /* cross-origin */ }
  }

  // 2 · confirm by substitution, which is the only reliable attribution
  const winner = candidates.find((c) => {
    const saved = c._rule.style.getPropertyValue(prop);
    const prio = c._rule.style.getPropertyPriority(prop);
    c._rule.style.removeProperty(prop);
    const changed = getComputedStyle(el).getPropertyValue(prop) !== resolved;
    c._rule.style.setProperty(prop, saved, prio);   // always restore
    return changed;
  });

  // 3 · report the inline style separately — it is outside every layer
  const inline = el.style.getPropertyValue(prop) || null;

  return {
    resolved,
    inline,
    winner: winner
      ? { layer: winner.layer, selector: winner.selector,
          important: winner.important, value: winner.value }
      : '(inherited or user-agent — no author rule is responsible)',
    candidates: candidates.map(({ _rule, ...rest }) => rest),
  };
}

Frequently Asked Questions

Is there a JavaScript API that reports which layer won?

There is not. getComputedStyle returns resolved values with no provenance attached, and CSSStyleDeclaration carries no layer information. The CSSOM does expose the layer structureCSSLayerBlockRule and CSSLayerStatementRule — but nothing links a resolved value back to the declaration that produced it. Every technique on this page is therefore an inference, and substitution is the one that produces proof rather than a strong guess.

Why does the Styles panel show several rules without saying which layer won?

Because it resolves per rule, not per property. The panel lists matching rules in cascade order and strikes through the individual declarations that lost, which is accurate but easy to misread when one rule wins some of its properties and loses others — a very common shape in a layered codebase. The Computed tab resolves each property independently and names the single rule responsible, which is the answer you were looking for.

Does layer order or specificity decide the winner?

Layer order is compared first, so two candidates in different layers are settled before specificity is even looked at — a single class in a higher layer beats an ID selector in a lower one. Specificity only breaks ties within a layer. That makes “are these rules in the same layer?” the first and most useful question in any investigation, because a “no” eliminates half the possible explanations immediately.

Up: Debugging Specificity LeaksSpecificity Management & Conflict Resolution