Interactive demo

Cascade repair lab

Reproduce an override conflict and compare source order, selector escalation, and component-owned state as different fixes.

Live demo

Cascade repair lab

HTML used in this demo
<main class="cascade-lab" data-cascade-lab data-strategy="source" data-utility="on">
  <header class="lab-header">
    <p class="eyebrow">Cascade conflict</p>
    <h1>Fix the owner, not just the selector.</h1>
    <p>The same button has a destructive state and a muted utility class. The winning rule changes depending on how the conflict is repaired.</p>
  </header>

  <form class="control-panel" aria-label="Cascade repair controls">
    <label>
      <span>Repair strategy</span>
      <select name="strategy">
        <option value="source">Source order only</option>
        <option value="specificity">Escalate selector weight</option>
        <option value="owner">Move state to owning rule</option>
      </select>
    </label>

    <label class="toggle">
      <input type="checkbox" name="utility" checked />
      <span>Include <code>.u-muted</code> class</span>
    </label>
  </form>

  <section class="conflict-stage">
    <div class="checkout-panel">
      <p class="eyebrow">Account settings</p>
      <h2>Delete saved payment method</h2>
      <p>This action should not look secondary when it is destructive.</p>
      <button id="confirm" class="demo-button is-critical u-muted" data-intent="danger">Delete method</button>
    </div>

    <ol class="rule-stack" aria-label="Competing cascade rules">
      <li data-rule="state">
        <code>.demo-button.is-critical</code>
        <span>Component state</span>
      </li>
      <li data-rule="utility">
        <code>.demo-button.u-muted</code>
        <span>Utility class later in source</span>
      </li>
      <li data-rule="specificity">
        <code>.checkout-panel #confirm.demo-button.is-critical</code>
        <span>Selector escalation</span>
      </li>
      <li data-rule="owner">
        <code>.demo-button[data-intent='danger']</code>
        <span>Component-owned intent rule</span>
      </li>
    </ol>
  </section>

  <aside class="diagnosis" aria-live="polite">
    <p class="eyebrow">Winning rule</p>
    <h2 data-winner>Utility wins by source order.</h2>
    <p data-explain>The muted utility appears later and overrides the destructive state, so the UI loses important meaning.</p>
  </aside>
</main>
CSS used in this demo
.cascade-lab {
  --ink: #171f29;
  --muted: #53606b;
  --line: #cbd4dc;
  --surface: #fffdf8;
  --panel: #f6f4ee;
  --accent: #8a5a15;
  --danger: #a33d2f;
  --neutral: #5c6874;
  display: grid;
  gap: 1rem;
  max-width: 68rem;
  margin: 0 auto;
  padding: 1.25rem;
  color: var(--ink);
}

.lab-header {
  display: grid;
  gap: 0.35rem;
  max-width: 54rem;
}

.eyebrow {
  margin: 0;
  color: var(--accent);
  font-size: 0.76rem;
  font-weight: 800;
  letter-spacing: 0;
  text-transform: uppercase;
}

h1,
h2,
p {
  margin-block: 0;
}

h1 {
  font-size: clamp(1.8rem, 5vw, 3.1rem);
  line-height: 1.02;
}

.control-panel {
  display: grid;
  gap: 0.85rem;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  align-items: end;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  padding: 0.9rem;
}

.control-panel label {
  display: grid;
  gap: 0.45rem;
  color: var(--muted);
  font-weight: 700;
}

.control-panel select {
  min-height: 2.55rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: #ffffff;
  padding-inline: 0.65rem;
}

.toggle {
  grid-template-columns: auto 1fr;
  align-items: center;
  align-self: center;
}

.toggle input {
  inline-size: 1.1rem;
  block-size: 1.1rem;
  accent-color: var(--accent);
}

.toggle code {
  color: var(--ink);
}

.conflict-stage {
  display: grid;
  gap: 1rem;
  grid-template-columns: minmax(0, 1fr) minmax(min(100%, 22rem), 0.85fr);
}

@media (max-width: 760px) {
  .conflict-stage {
    grid-template-columns: 1fr;
  }
}

.checkout-panel,
.rule-stack,
.diagnosis {
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
}

.checkout-panel {
  display: grid;
  gap: 0.75rem;
  align-content: start;
  min-height: 18rem;
  padding: 1.1rem;
}

.checkout-panel h2 {
  font-size: 1.55rem;
}

.checkout-panel p:not(.eyebrow) {
  color: var(--muted);
  line-height: 1.5;
}

.demo-button {
  justify-self: start;
  min-height: 2.8rem;
  border: 0;
  border-radius: 6px;
  background: #23303d;
  color: #ffffff;
  padding-inline: 1rem;
  font-weight: 800;
}

.demo-button.is-critical {
  background: var(--danger);
}

.demo-button.u-muted {
  background: var(--neutral);
}

[data-strategy='specificity'] .checkout-panel #confirm.demo-button.is-critical {
  background: var(--danger);
}

[data-strategy='owner'] .demo-button[data-intent='danger'] {
  background: var(--danger);
}

.rule-stack {
  display: grid;
  gap: 0.55rem;
  margin: 0;
  padding: 0.9rem;
  list-style: none;
}

.rule-stack li {
  display: grid;
  gap: 0.25rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: #ffffff;
  padding: 0.7rem;
}

.rule-stack code {
  color: var(--ink);
  font-size: 0.82rem;
  overflow-wrap: anywhere;
}

.rule-stack span {
  color: var(--muted);
  font-size: 0.88rem;
}

.rule-stack li[data-winner] {
  border-color: var(--accent);
  background: #fff2d5;
}

.diagnosis {
  display: grid;
  gap: 0.5rem;
  border-inline-start: 4px solid var(--accent);
  background: var(--panel);
  padding: 0.95rem 1rem;
}

.diagnosis h2 {
  font-size: 1.25rem;
}

.diagnosis p:not(.eyebrow) {
  color: var(--muted);
  line-height: 1.5;
}
JavaScript used in this demo
const lab = document.querySelector('[data-cascade-lab]');
const form = lab.querySelector('form');
const button = lab.querySelector('.demo-button');
const winner = lab.querySelector('[data-winner]');
const explain = lab.querySelector('[data-explain]');
const rules = Array.from(lab.querySelectorAll('[data-rule]'));

const outcomes = {
  source: {
    rule: 'utility',
    title: 'Utility wins by source order.',
    explain: 'The muted utility appears later and overrides the destructive state, so the UI loses important meaning.',
  },
  specificity: {
    rule: 'specificity',
    title: 'The escalated selector wins.',
    explain: 'The symptom is fixed, but the new selector is expensive. The next override now has to beat an ID-level rule.',
  },
  owner: {
    rule: 'owner',
    title: 'The component-owned state wins.',
    explain: 'The destructive intent is represented as component state, so the important visual meaning is owned where the behavior lives.',
  },
  noUtility: {
    rule: 'state',
    title: 'The component state wins.',
    explain: 'Without the competing utility, the normal state selector is enough. No escalation is needed.',
  },
};

function render() {
  const data = new FormData(form);
  const strategy = data.get('strategy') || 'source';
  const hasUtility = data.get('utility') === 'on';
  const outcome = hasUtility ? outcomes[strategy] : outcomes.noUtility;

  lab.dataset.strategy = strategy;
  lab.dataset.utility = hasUtility ? 'on' : 'off';
  button.classList.toggle('u-muted', hasUtility);

  winner.textContent = outcome.title;
  explain.textContent = outcome.explain;

  rules.forEach((rule) => {
    const active = rule.dataset.rule === outcome.rule;
    rule.toggleAttribute('data-winner', active);
    if (active) {
      rule.setAttribute('aria-current', 'true');
    } else {
      rule.removeAttribute('aria-current');
    }
  });
}

form.addEventListener('input', render);
render();

What this demo is for

Use this demo when an override fails and you need to decide whether the fix belongs in selector weight, source order, cascade layers, or component state.

Try this

  • Start with source order and the muted utility enabled: the destructive button loses its visual intent.
  • Switch to selector escalation and notice that it fixes the symptom while creating a more expensive selector.
  • Switch to component-owned state to keep the important state rule explicit instead of relying on a stronger selector.

Related guides