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.
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”.
Verification
- Confirm the answer by removing the identified declaration and watching the computed value fall through to the next candidate.
- Check the runner-up is what you expect — if it is not, there is another rule you have not accounted for.
- 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.
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 structure — CSSLayerBlockRule 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.
Related
- Debugging Specificity Leaks — the wider diagnostic workflow
- Debugging Unlayered Author Styles in DevTools — the most common answer these techniques produce
- Cascade Origins, User Agent and User Styles — what to check when no author rule is responsible
- Debugging Layer Differences Between Chrome, Firefox and Safari — the same question across engines
Up: Debugging Specificity Leaks → Specificity Management & Conflict Resolution