22 CSS Transitions16 / 22

Pure CSSMIT licensed

Has Selector Driven Sibling Transitions

:has() lets a state on one element drive a transition on a sibling or an ancestor, which used to require JavaScript for no reason other than selector direction. Here a field's validity transitions the styling of the summary row beside it. Includes the specificity surprise: :has() takes the specificity of its most specific argument, so it can quietly outrank the rule you expected to win.

Published Updated

Live Demo
Try it

The code

<section class="trn-16" aria-labelledby="trn-16-heading">
  <div class="trn-16__stage">
    <div class="trn-16__theme">
      <input class="trn-16__themecb" type="checkbox" id="trn-16-theme">
      <label class="trn-16__themebtn" for="trn-16-theme">
        <span class="trn-16__themeico" aria-hidden="true">
          <svg class="trn-16__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
          <svg class="trn-16__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
        </span>
        <span>Theme</span>
      </label>
    </div>
    <header class="trn-16__head">
      <p class="trn-16__kicker">:has()</p>
      <h2 class="trn-16__heading" id="trn-16-heading">A field's state, styling its neighbour</h2>
      <p class="trn-16__sub">Type a short value and Tab out. The summary row above the field reacts — no script, no class.</p>
    </header>

    <fieldset class="trn-16__card">
      <legend class="trn-16__legend">Transfer to Fern Holdings</legend>

      <div class="trn-16__group">
        <div class="trn-16__summary">
          <span class="trn-16__sicon" aria-hidden="true">
            <svg class="trn-16__ok" viewBox="0 0 20 20" width="16" height="16"><path d="m4.8 10.4 3.2 3.2 7.2-7.4" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
            <svg class="trn-16__no" viewBox="0 0 20 20" width="16" height="16"><path d="M10 3.4 18 16.6H2L10 3.4Z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 8v3.6M10 13.4v1" stroke="currentColor" stroke-width="1.7"/></svg>
          </span>
          <span class="trn-16__stext">Reference looks valid</span>
          <span class="trn-16__samount">GBP 4,820.00</span>
        </div>

        <label class="trn-16__label" for="trn-16-ref">Payment reference</label>
        <input class="trn-16__field" id="trn-16-ref" type="text" required minlength="6" placeholder="INV-40921" aria-describedby="trn-16-help">
        <p class="trn-16__help" id="trn-16-help">Six characters or more. Validity is checked after you leave the field.</p>
      </div>

      <code class="trn-16__code">.trn-16__group:has(.trn-16__field:user-invalid) .trn-16__summary { … }</code>
    </fieldset>

    <p class="trn-16__foot">The summary sits <em>before</em> the field in the DOM. No sibling combinator can reach backwards — <code>:has()</code> on their shared parent can.</p>
  </div>
</section>
.trn-16 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #f6f8f7;
  --page: oklch(0.97 0.005 165);
  --card: #ffffff;
  --ink: #17211d;
  --ink: oklch(0.24 0.014 165);
  --muted: #5c6a64;
  --muted: oklch(0.51 0.014 165);
  --rule: #dde4e0;
  --rule: oklch(0.9 0.008 165);
  --mint: #10a37f;
  --mint: oklch(0.62 0.11 168);
  --flag: #b4483a;
  --flag: oklch(0.52 0.15 30);
  --font-display: "Lucida Grande", "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.trn-16 *,
.trn-16 *::before,
.trn-16 *::after {
  box-sizing: border-box;
}

.trn-16__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(26px, 5vw, 60px) 16px;
}

.trn-16__head {
  inline-size: 100%;
  max-inline-size: 46rem;
  min-inline-size: 0;
  margin: 0 0 24px;
}

.trn-16__kicker {
  margin: 0 0 10px;
  font: 500 12px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.08em;
  color: var(--mint);
}

.trn-16__heading {
  margin: 0 0 10px;
  font: 600 clamp(24px, 4.4vw, 34px)/1.14 var(--font-display);
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.trn-16__sub {
  margin: 0;
  max-inline-size: 50ch;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.trn-16__card {
  inline-size: 100%;
  max-inline-size: 46rem;
  min-inline-size: 0;
  margin: 0;
  padding: 20px;
  background: var(--card);
  border: 0;
  border-block-start: 2px solid var(--ink);
}

.trn-16__legend {
  padding: 0;
  font: 600 12px/1 var(--font-display);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.trn-16__group {
  display: grid;
  gap: 10px;
  margin-block-start: 18px;
}

.trn-16__summary {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 12px;
  align-items: center;
  min-inline-size: 0;
  padding: 14px 0;
  border-block: 1px solid var(--rule);
  color: var(--muted);
  transition: color 300ms ease, border-color 300ms ease, padding-inline-start 300ms ease;
}

.trn-16__sicon {
  flex: none;
  display: grid;
  place-items: center;
  inline-size: 18px;
  block-size: 18px;
  color: var(--mint);
}

.trn-16__ok,
.trn-16__no {
  grid-area: 1 / 1;
  transition: opacity 260ms ease;
}

.trn-16__no {
  opacity: 0;
}

.trn-16__stext {
  font: 600 13.5px/1.3 var(--font-display);
  color: var(--ink);
}

.trn-16__samount {
  margin-inline-start: auto;
  font: 400 13px/1 ui-monospace, Menlo, monospace;
}

.trn-16__label {
  font: 600 12px/1 var(--font-display);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.trn-16__field {
  min-height: 44px;
  padding: 0 12px;
  background: var(--page);
  border: 1px solid var(--rule);
  border-radius: 4px;
  font: 400 14px/1 ui-monospace, Menlo, monospace;
  color: var(--ink);
  transition: border-color 260ms ease, background-color 260ms ease;
}

.trn-16__field:focus-visible {
  outline: 2px solid var(--mint);
  outline-offset: 2px;
  background: var(--card);
}

.trn-16__help {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--muted);
}

@supports selector(:has(*)) {
  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__summary {
    color: var(--flag);
    border-color: var(--flag);
    padding-inline-start: 10px;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__sicon {
    color: var(--flag);
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__ok {
    opacity: 0;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__no {
    opacity: 1;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__stext {
    color: var(--flag);
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__stext::after {
    content: " — six characters minimum";
    font-weight: 400;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__field {
    border-color: var(--flag);
  }
}

.trn-16__code {
  display: block;
  margin-block-start: 18px;
  padding: 10px 12px;
  background: var(--page);
  border-inline-start: 2px solid var(--mint);
  font: 400 11.5px/1.55 ui-monospace, Menlo, monospace;
  color: var(--muted);
  overflow-wrap: anywhere;
}

.trn-16__foot {
  inline-size: 100%;
  max-inline-size: 46rem;
  margin: 18px 0 0;
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--muted);
}

.trn-16__foot code {
  font: 400 11.5px/1 ui-monospace, Menlo, monospace;
  color: var(--ink);
}

@media (prefers-reduced-motion: reduce) {
  .trn-16__summary {
    transition: color 150ms ease, border-color 150ms ease;
  }

  @supports selector(:has(*)) {
    .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__summary {
      padding-inline-start: 0;
    }
  }
}

@media (prefers-color-scheme: dark) {
  .trn-16 {
    --page: #0f1412;
    --page: oklch(0.19 0.008 165);
    --card: #161d1a;
    --card: oklch(0.23 0.01 165);
    --ink: #e9f0ec;
    --ink: oklch(0.95 0.01 165);
    --muted: #93a29b;
    --muted: oklch(0.71 0.014 165);
    --rule: #26302c;
    --rule: oklch(0.3 0.012 165);
    --mint: #48d3ac;
    --mint: oklch(0.8 0.12 168);
    --flag: #ff8c7a;
    --flag: oklch(0.76 0.14 32);
  }

  .trn-16__card {
    border-block-start-color: var(--mint);
  }
}

[data-theme="dark"] .trn-16 {
  --page: #0f1412;
  --card: #161d1a;
  --ink: #e9f0ec;
  --muted: #93a29b;
  --rule: #26302c;
  --mint: #48d3ac;
  --flag: #ff8c7a;
}

[data-theme="dark"] .trn-16__card {
  border-block-start-color: var(--mint);
}

.trn-16__theme {
  position: relative;
  inline-size: 100%;
  min-inline-size: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0 0 14px;
}

.trn-16__themecb {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-16__themebtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
  font: 500 11px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: color 200ms ease, opacity 200ms ease;
}

.trn-16__themebtn:hover {
  color: var(--ink);
}

.trn-16__themecb:focus-visible + .trn-16__themebtn {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.trn-16__themeico {
  display: grid;
  place-items: center;
  inline-size: 15px;
  block-size: 15px;
}

.trn-16__themeico svg {
  grid-area: 1 / 1;
  transition: opacity 200ms ease;
}

.trn-16__sun {
  opacity: 1;
}

.trn-16__moon {
  opacity: 0.34;
}

.trn-16:has(#trn-16-theme:checked) .trn-16__sun {
  opacity: 0.34;
}

.trn-16:has(#trn-16-theme:checked) .trn-16__moon {
  opacity: 1;
}

@media (prefers-color-scheme: light) {
  .trn-16:has(#trn-16-theme:checked) {
    --page: #0f1412;
    --page: oklch(0.19 0.008 165);
    --card: #161d1a;
    --card: oklch(0.23 0.01 165);
    --ink: #e9f0ec;
    --ink: oklch(0.95 0.01 165);
    --muted: #93a29b;
    --muted: oklch(0.71 0.014 165);
    --rule: #26302c;
    --rule: oklch(0.3 0.012 165);
    --mint: #48d3ac;
    --mint: oklch(0.8 0.12 168);
    --flag: #ff8c7a;
    --flag: oklch(0.76 0.14 32);
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__card {
    border-block-start-color: var(--mint);
  }
}

@media (prefers-color-scheme: dark) {
  .trn-16:has(#trn-16-theme:checked) {
    --page: #f6f8f7;
    --page: oklch(0.97 0.005 165);
    --card: #ffffff;
    --ink: #17211d;
    --ink: oklch(0.24 0.014 165);
    --muted: #5c6a64;
    --muted: oklch(0.51 0.014 165);
    --rule: #dde4e0;
    --rule: oklch(0.9 0.008 165);
    --mint: #10a37f;
    --mint: oklch(0.62 0.11 168);
    --flag: #b4483a;
    --flag: oklch(0.52 0.15 30);
    --font-display: "Lucida Grande", "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
    background: var(--page);
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__card {
    inline-size: 100%;
    max-inline-size: 46rem;
    min-inline-size: 0;
    margin: 0;
    padding: 20px;
    background: var(--card);
    border: 0;
    border-block-start: 2px solid var(--ink);
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__sun {
    opacity: 1;
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__moon {
    opacity: 0.34;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Has Selector Driven Sibling Transitions
Source: https://codefronts.com/motion/css-transition-designs/card-tilt-3d-hover/

:has() lets a state on one element drive a transition on a sibling or an ancestor, which used to require JavaScript for no reason other than selector direction. Here a field's validity transitions the styling of the summary row beside it. Includes the specificity surprise: :has() takes the specificity of its most specific argument, so it can quietly outrank the rule you expected to win.
## HTML
```html
<section class="trn-16" aria-labelledby="trn-16-heading">
  <div class="trn-16__stage">
    <div class="trn-16__theme">
      <input class="trn-16__themecb" type="checkbox" id="trn-16-theme">
      <label class="trn-16__themebtn" for="trn-16-theme">
        <span class="trn-16__themeico" aria-hidden="true">
          <svg class="trn-16__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
          <svg class="trn-16__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
        </span>
        <span>Theme</span>
      </label>
    </div>
    <header class="trn-16__head">
      <p class="trn-16__kicker">:has()</p>
      <h2 class="trn-16__heading" id="trn-16-heading">A field's state, styling its neighbour</h2>
      <p class="trn-16__sub">Type a short value and Tab out. The summary row above the field reacts — no script, no class.</p>
    </header>

    <fieldset class="trn-16__card">
      <legend class="trn-16__legend">Transfer to Fern Holdings</legend>

      <div class="trn-16__group">
        <div class="trn-16__summary">
          <span class="trn-16__sicon" aria-hidden="true">
            <svg class="trn-16__ok" viewBox="0 0 20 20" width="16" height="16"><path d="m4.8 10.4 3.2 3.2 7.2-7.4" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
            <svg class="trn-16__no" viewBox="0 0 20 20" width="16" height="16"><path d="M10 3.4 18 16.6H2L10 3.4Z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 8v3.6M10 13.4v1" stroke="currentColor" stroke-width="1.7"/></svg>
          </span>
          <span class="trn-16__stext">Reference looks valid</span>
          <span class="trn-16__samount">GBP 4,820.00</span>
        </div>

        <label class="trn-16__label" for="trn-16-ref">Payment reference</label>
        <input class="trn-16__field" id="trn-16-ref" type="text" required minlength="6" placeholder="INV-40921" aria-describedby="trn-16-help">
        <p class="trn-16__help" id="trn-16-help">Six characters or more. Validity is checked after you leave the field.</p>
      </div>

      <code class="trn-16__code">.trn-16__group:has(.trn-16__field:user-invalid) .trn-16__summary { … }</code>
    </fieldset>

    <p class="trn-16__foot">The summary sits <em>before</em> the field in the DOM. No sibling combinator can reach backwards — <code>:has()</code> on their shared parent can.</p>
  </div>
</section>
```
## CSS
```css
.trn-16 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #f6f8f7;
  --page: oklch(0.97 0.005 165);
  --card: #ffffff;
  --ink: #17211d;
  --ink: oklch(0.24 0.014 165);
  --muted: #5c6a64;
  --muted: oklch(0.51 0.014 165);
  --rule: #dde4e0;
  --rule: oklch(0.9 0.008 165);
  --mint: #10a37f;
  --mint: oklch(0.62 0.11 168);
  --flag: #b4483a;
  --flag: oklch(0.52 0.15 30);
  --font-display: "Lucida Grande", "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.trn-16 *,
.trn-16 *::before,
.trn-16 *::after {
  box-sizing: border-box;
}

.trn-16__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(26px, 5vw, 60px) 16px;
}

.trn-16__head {
  inline-size: 100%;
  max-inline-size: 46rem;
  min-inline-size: 0;
  margin: 0 0 24px;
}

.trn-16__kicker {
  margin: 0 0 10px;
  font: 500 12px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.08em;
  color: var(--mint);
}

.trn-16__heading {
  margin: 0 0 10px;
  font: 600 clamp(24px, 4.4vw, 34px)/1.14 var(--font-display);
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.trn-16__sub {
  margin: 0;
  max-inline-size: 50ch;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.trn-16__card {
  inline-size: 100%;
  max-inline-size: 46rem;
  min-inline-size: 0;
  margin: 0;
  padding: 20px;
  background: var(--card);
  border: 0;
  border-block-start: 2px solid var(--ink);
}

.trn-16__legend {
  padding: 0;
  font: 600 12px/1 var(--font-display);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.trn-16__group {
  display: grid;
  gap: 10px;
  margin-block-start: 18px;
}

.trn-16__summary {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 12px;
  align-items: center;
  min-inline-size: 0;
  padding: 14px 0;
  border-block: 1px solid var(--rule);
  color: var(--muted);
  transition: color 300ms ease, border-color 300ms ease, padding-inline-start 300ms ease;
}

.trn-16__sicon {
  flex: none;
  display: grid;
  place-items: center;
  inline-size: 18px;
  block-size: 18px;
  color: var(--mint);
}

.trn-16__ok,
.trn-16__no {
  grid-area: 1 / 1;
  transition: opacity 260ms ease;
}

.trn-16__no {
  opacity: 0;
}

.trn-16__stext {
  font: 600 13.5px/1.3 var(--font-display);
  color: var(--ink);
}

.trn-16__samount {
  margin-inline-start: auto;
  font: 400 13px/1 ui-monospace, Menlo, monospace;
}

.trn-16__label {
  font: 600 12px/1 var(--font-display);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.trn-16__field {
  min-height: 44px;
  padding: 0 12px;
  background: var(--page);
  border: 1px solid var(--rule);
  border-radius: 4px;
  font: 400 14px/1 ui-monospace, Menlo, monospace;
  color: var(--ink);
  transition: border-color 260ms ease, background-color 260ms ease;
}

.trn-16__field:focus-visible {
  outline: 2px solid var(--mint);
  outline-offset: 2px;
  background: var(--card);
}

.trn-16__help {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--muted);
}

@supports selector(:has(*)) {
  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__summary {
    color: var(--flag);
    border-color: var(--flag);
    padding-inline-start: 10px;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__sicon {
    color: var(--flag);
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__ok {
    opacity: 0;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__no {
    opacity: 1;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__stext {
    color: var(--flag);
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__stext::after {
    content: " — six characters minimum";
    font-weight: 400;
  }

  .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__field {
    border-color: var(--flag);
  }
}

.trn-16__code {
  display: block;
  margin-block-start: 18px;
  padding: 10px 12px;
  background: var(--page);
  border-inline-start: 2px solid var(--mint);
  font: 400 11.5px/1.55 ui-monospace, Menlo, monospace;
  color: var(--muted);
  overflow-wrap: anywhere;
}

.trn-16__foot {
  inline-size: 100%;
  max-inline-size: 46rem;
  margin: 18px 0 0;
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--muted);
}

.trn-16__foot code {
  font: 400 11.5px/1 ui-monospace, Menlo, monospace;
  color: var(--ink);
}

@media (prefers-reduced-motion: reduce) {
  .trn-16__summary {
    transition: color 150ms ease, border-color 150ms ease;
  }

  @supports selector(:has(*)) {
    .trn-16__group:has(.trn-16__field:user-invalid) .trn-16__summary {
      padding-inline-start: 0;
    }
  }
}

@media (prefers-color-scheme: dark) {
  .trn-16 {
    --page: #0f1412;
    --page: oklch(0.19 0.008 165);
    --card: #161d1a;
    --card: oklch(0.23 0.01 165);
    --ink: #e9f0ec;
    --ink: oklch(0.95 0.01 165);
    --muted: #93a29b;
    --muted: oklch(0.71 0.014 165);
    --rule: #26302c;
    --rule: oklch(0.3 0.012 165);
    --mint: #48d3ac;
    --mint: oklch(0.8 0.12 168);
    --flag: #ff8c7a;
    --flag: oklch(0.76 0.14 32);
  }

  .trn-16__card {
    border-block-start-color: var(--mint);
  }
}

[data-theme="dark"] .trn-16 {
  --page: #0f1412;
  --card: #161d1a;
  --ink: #e9f0ec;
  --muted: #93a29b;
  --rule: #26302c;
  --mint: #48d3ac;
  --flag: #ff8c7a;
}

[data-theme="dark"] .trn-16__card {
  border-block-start-color: var(--mint);
}

.trn-16__theme {
  position: relative;
  inline-size: 100%;
  min-inline-size: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0 0 14px;
}

.trn-16__themecb {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-16__themebtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
  font: 500 11px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: color 200ms ease, opacity 200ms ease;
}

.trn-16__themebtn:hover {
  color: var(--ink);
}

.trn-16__themecb:focus-visible + .trn-16__themebtn {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.trn-16__themeico {
  display: grid;
  place-items: center;
  inline-size: 15px;
  block-size: 15px;
}

.trn-16__themeico svg {
  grid-area: 1 / 1;
  transition: opacity 200ms ease;
}

.trn-16__sun {
  opacity: 1;
}

.trn-16__moon {
  opacity: 0.34;
}

.trn-16:has(#trn-16-theme:checked) .trn-16__sun {
  opacity: 0.34;
}

.trn-16:has(#trn-16-theme:checked) .trn-16__moon {
  opacity: 1;
}

@media (prefers-color-scheme: light) {
  .trn-16:has(#trn-16-theme:checked) {
    --page: #0f1412;
    --page: oklch(0.19 0.008 165);
    --card: #161d1a;
    --card: oklch(0.23 0.01 165);
    --ink: #e9f0ec;
    --ink: oklch(0.95 0.01 165);
    --muted: #93a29b;
    --muted: oklch(0.71 0.014 165);
    --rule: #26302c;
    --rule: oklch(0.3 0.012 165);
    --mint: #48d3ac;
    --mint: oklch(0.8 0.12 168);
    --flag: #ff8c7a;
    --flag: oklch(0.76 0.14 32);
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__card {
    border-block-start-color: var(--mint);
  }
}

@media (prefers-color-scheme: dark) {
  .trn-16:has(#trn-16-theme:checked) {
    --page: #f6f8f7;
    --page: oklch(0.97 0.005 165);
    --card: #ffffff;
    --ink: #17211d;
    --ink: oklch(0.24 0.014 165);
    --muted: #5c6a64;
    --muted: oklch(0.51 0.014 165);
    --rule: #dde4e0;
    --rule: oklch(0.9 0.008 165);
    --mint: #10a37f;
    --mint: oklch(0.62 0.11 168);
    --flag: #b4483a;
    --flag: oklch(0.52 0.15 30);
    --font-display: "Lucida Grande", "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
    background: var(--page);
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__card {
    inline-size: 100%;
    max-inline-size: 46rem;
    min-inline-size: 0;
    margin: 0;
    padding: 20px;
    background: var(--card);
    border: 0;
    border-block-start: 2px solid var(--ink);
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__sun {
    opacity: 1;
  }

  .trn-16:has(#trn-16-theme:checked) .trn-16__moon {
    opacity: 0.34;
  }
}
```

How this works

:has() reads forwards and downwards, which is what CSS never had. .row:has(input:user-invalid) matches the row because of something inside it, so a state that exists only on the field can style anything the row contains — including elements that come before the field in the DOM. The transition itself is ordinary; only the selector is new.

Choose the validity pseudo-class carefully. :invalid matches an empty required field before the user has typed anything, so a form built on it is red on arrival. :user-invalid waits for interaction — the field must have been edited and blurred, or submitted — which is what a human would call invalid. Pairing :has() with :user-invalid is what makes the pattern usable.

The specificity surprise bites in real code. :has() contributes the specificity of its most specific argument, so .row:has(#total) carries an id's weight and will beat a later .row.error rule you assumed would win. When a :has()-driven transition refuses to be overridden, count the specificity of what is inside the parentheses, not just the outside.

It is not free, but it is cheap enough. Engines evaluate :has() against a bounded subtree, so scoping it to a small container is fine; anchoring it high — body:has(.something) — asks for invalidation across the page on every relevant DOM change. Keep the anchor near the state, which is what this row does.

Make it yours

  • Swap :user-invalid for :valid to celebrate correctness instead of flagging errors.
  • Move the :has() anchor up one level to tint the whole card instead of the summary row.
  • Add a second condition with :has(input:user-invalid, select:invalid) to cover more field types.
  • Transition --tone only, and let every child read it, to reduce four transitions to one.
  • Give the invalid state a slower duration than the valid state so errors feel deliberate.
  • Retint through --mint and --flag to match your own semantic palette.

Gotchas — read before shipping

  • :has() takes the specificity of its most specific argument, so a selector containing an id will beat class-based rules written later.
  • :invalid matches empty required fields immediately, painting a fresh form red — use :user-invalid for anything a human sees.
  • Anchoring :has() on a high ancestor widens the invalidation scope on every DOM change inside it; keep the anchor close to the state.
  • :has() cannot be nested inside another :has(), and a bare :has() with no compound before it is not the same as :scope:has() in every engine.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

:has() sets the floor (Chrome 105, Safari 15.4, Firefox 121) and the oklch() palette raises Chrome to 111. The rules sit inside @supports selector(:has(*)), so an engine without :has() renders the summary row in its neutral state — the field still validates natively, the row just does not react. The in-demo theme toggle is pure CSS and relies on :has(), so an engine without it keeps the operating-system theme instead of flipping.

Techniques used in this demo

Search CodeFronts

Loading…