20 CSS Form Validation12 / 20

Light JSMIT licensed

Validation Summary Box with Has Selector

A summary panel above the form that appears only while the form contains a field the user has left invalid — .fv-12__form:has(:user-invalid) .fv-12__summary, and nothing else. Each line links to its field by id so clicking moves focus straight there. The script only keeps the list text current; whether the panel is on screen is decided entirely in CSS, and that split is the point.

Published

Live Demo
Try it

The code

<section class="fv-12" aria-labelledby="fv-12-h">
  <fieldset class="fv-12__theme">
    <legend class="fv-12__themelegend">Theme</legend>
    <input class="fv-12__themeinput" type="radio" name="fv-12-theme" id="fv-12-theme-auto" checked>
    <label class="fv-12__themeopt" for="fv-12-theme-auto">Auto</label>
    <input class="fv-12__themeinput" type="radio" name="fv-12-theme" id="fv-12-theme-light">
    <label class="fv-12__themeopt" for="fv-12-theme-light">Light</label>
    <input class="fv-12__themeinput" type="radio" name="fv-12-theme" id="fv-12-theme-dark">
    <label class="fv-12__themeopt" for="fv-12-theme-dark">Dark</label>
  </fieldset>
  <div class="fv-12__stage">
    <form class="fv-12__form" aria-labelledby="fv-12-h">
      <header class="fv-12__head">
        <p class="fv-12__eyebrow">Sable · access request</p>
        <h2 class="fv-12__h" id="fv-12-h">The panel is a selector, not a state flag</h2>
      </header>

      <div class="fv-12__summary" role="group" aria-labelledby="fv-12-sum-h">
        <div class="fv-12__sumhead">
          <svg class="fv-12__sumicon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle cx="10" cy="10" r="8.2" fill="none" stroke="currentColor" stroke-width="1.7"></circle><path d="M10 5.6v5.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path><circle cx="10" cy="14.3" r="1.1" fill="currentColor"></circle></svg>
          <h3 class="fv-12__sumtitle" id="fv-12-sum-h">Before this can be sent</h3>
          <p class="fv-12__count">2 fields need attention</p>
        </div>
        <ul class="fv-12__list">
          <li><a href="#fv-12-email">Work email — Enter a valid email address</a></li>
          <li><a href="#fv-12-seats">Seats — Enter a number from 1 to 500</a></li>
        </ul>
      </div>

      <div class="fv-12__fields">
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-name">Requester</label>
          <input class="fv-12__input" id="fv-12-name" name="fv-12-name" type="text" required autocomplete="name" placeholder="Jordan Lee" value="Jordan Lee">
        </div>
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-email">Work email</label>
          <input class="fv-12__input" id="fv-12-email" name="fv-12-email" type="email" required autocomplete="email" placeholder="jordan@sable.io" value="jordan.sable.io">
        </div>
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-seats">Seats</label>
          <input class="fv-12__input" id="fv-12-seats" name="fv-12-seats" type="number" required min="1" max="500" placeholder="24" value="0">
        </div>
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-team">Team</label>
          <input class="fv-12__input" id="fv-12-team" name="fv-12-team" type="text" required placeholder="Platform" value="Platform">
        </div>
      </div>

      <div class="fv-12__foot">
        <button class="fv-12__btn" type="button">Send request</button>
        <p class="fv-12__hint">Visibility is pure <code>:has()</code>. The list text is the only thing script touches.</p>
      </div>
    </form>
  </div>
</section>
.fv-12 {
  --page: #000000;
  --surface: rgba(20, 22, 26, 0.72);
  --field: #0c0d10;
  --ink: #f4f6fa;
  --muted: #9096a4;
  --rule: #23262e;
  --accent: #22e0d0;
  --error: #ff4b5c;
  --error-ink: #ff8f9a;
  --glass-blur: 14px;
  --font-display: "Geist", "Inter Tight", system-ui, sans-serif;
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, sans-serif;
}

@supports (color: oklch(0 0 0)) {
  .fv-12 {
    --accent: oklch(0.83 0.16 182);
    --error: oklch(0.65 0.22 18);
    --error-ink: oklch(0.76 0.16 18);
  }
}

.fv-12 *,
.fv-12 *::before,
.fv-12 *::after {
  box-sizing: border-box;
}

.fv-12 [hidden] {
  display: none;
}

.fv-12__stage {
  display: grid;
  place-items: center;
  min-block-size: 100vh;
  max-inline-size: 100%;
  padding: clamp(1.25rem, 4vw, 3.5rem) clamp(1rem, 4vw, 2.5rem);
  background-image: radial-gradient(90% 60% at 20% 0%, rgba(34, 224, 208, 0.12) 0%, transparent 60%), radial-gradient(70% 50% at 100% 100%, rgba(255, 75, 92, 0.1) 0%, transparent 60%);
}

.fv-12__form {
  inline-size: min(100%, 34rem);
  min-inline-size: 0;
  display: grid;
  gap: 1.125rem;
  padding: clamp(1.125rem, 3.5vw, 1.75rem);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 1rem;
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  backdrop-filter: blur(var(--glass-blur)) saturate(140%);
}

.fv-12__head {
  min-inline-size: 0;
}

.fv-12__eyebrow {
  margin: 0 0 0.375rem;
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
}

.fv-12__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.2rem, 3.4vw, 1.5rem);
  font-weight: 620;
  line-height: 1.2;
  letter-spacing: -0.025em;
}

.fv-12__summary {
  display: none;
  gap: 0.6875rem;
  min-inline-size: 0;
  padding: 0.9375rem 1rem;
  border: 1px solid var(--error);
  border-radius: 0.75rem;
  background: rgba(255, 75, 92, 0.1);
}

.fv-12__form:has(.fv-12__input:invalid:not(:placeholder-shown)) .fv-12__summary {
  display: grid;
}

.fv-12__form:has(.fv-12__input:user-invalid) .fv-12__summary {
  display: grid;
}

.fv-12__sumhead {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 0.5rem 0.625rem;
  min-inline-size: 0;
}

.fv-12__sumicon {
  grid-row: span 2;
  inline-size: 1.375rem;
  block-size: 1.375rem;
  color: var(--error);
}

.fv-12__sumtitle {
  margin: 0;
  min-inline-size: 0;
  font-family: var(--font-display);
  font-size: 0.9375rem;
  font-weight: 620;
  color: var(--error-ink);
}

.fv-12__count {
  margin: 0;
  min-inline-size: 0;
  font-size: 0.75rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.fv-12__list {
  margin: 0;
  padding-inline-start: 1.75rem;
  display: grid;
  gap: 0.375rem;
  min-inline-size: 0;
}

.fv-12__list li {
  min-inline-size: 0;
  font-size: 0.8125rem;
  line-height: 1.45;
}

.fv-12__list a {
  color: var(--error-ink);
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
}

.fv-12__list a:hover {
  color: #ffffff;
}

.fv-12__list a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 0.125rem;
}

.fv-12__fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
  gap: 0.875rem;
  min-inline-size: 0;
}

.fv-12__field {
  display: grid;
  gap: 0.375rem;
  min-inline-size: 0;
}

.fv-12__label {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}

.fv-12__input {
  min-inline-size: 0;
  inline-size: 100%;
  block-size: 2.75rem;
  padding: 0 0.8125rem;
  font: inherit;
  font-size: 0.9375rem;
  color: var(--ink);
  background: var(--field);
  border: 1px solid var(--rule);
  border-radius: 0.5rem;
}

.fv-12__input::placeholder {
  color: #5f6674;
}

.fv-12__input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.fv-12__input:invalid:not(:placeholder-shown):not(:focus) {
  border-color: var(--error);
}

.fv-12__input:user-invalid {
  border-color: var(--error);
  background: #170a0d;
}

.fv-12__input:user-valid {
  border-color: #2f6b62;
}

.fv-12__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem 1rem;
  min-inline-size: 0;
}

.fv-12__btn {
  min-block-size: 2.75rem;
  min-inline-size: 44px;
  padding: 0.6875rem 1.375rem;
  font: inherit;
  font-size: 0.9375rem;
  font-weight: 650;
  color: #04100f;
  background: var(--accent);
  border: 0;
  border-radius: 0.5rem;
  cursor: pointer;
}

.fv-12__btn:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 3px;
}

.fv-12__hint {
  margin: 0;
  min-inline-size: 0;
  font-size: 0.75rem;
  line-height: 1.45;
  color: var(--muted);
}

.fv-12__hint code {
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 0.9em;
  color: var(--accent);
}

@media (prefers-color-scheme: light) {
  .fv-12:not(:has(#fv-12-theme-dark:checked)) {
    --page: #eef0f4;
    --surface: rgba(255, 255, 255, 0.78);
    --field: #ffffff;
    --ink: #14161c;
    --muted: #5c6472;
    --rule: #d6dae2;
    --accent: #0b8f84;
    --error: #cf2137;
    --error-ink: #a5162a;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__stage {
    background-image: radial-gradient(90% 60% at 20% 0%, rgba(11, 143, 132, 0.1) 0%, transparent 60%), radial-gradient(70% 50% at 100% 100%, rgba(207, 33, 55, 0.08) 0%, transparent 60%);
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__summary {
    background: rgba(207, 33, 55, 0.07);
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__input:user-invalid {
    background: #fdf2f3;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__input::placeholder {
    color: #9aa2b0;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__list a:hover {
    color: #14161c;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__btn {
    color: #ffffff;
  }
}

[data-theme="dark"] .fv-12:not(:has(#fv-12-theme-light:checked)) {
  --page: #000000;
  --surface: rgba(20, 22, 26, 0.72);
  --field: #0c0d10;
  --ink: #f4f6fa;
  --muted: #9096a4;
  --rule: #23262e;
  --accent: #22e0d0;
  --error: #ff4b5c;
  --error-ink: #ff8f9a;
}

[data-theme="dark"] .fv-12:not(:has(#fv-12-theme-light:checked)) .fv-12__btn {
  color: #04100f;
}

[data-theme="dark"] .fv-12:not(:has(#fv-12-theme-light:checked)) .fv-12__input:user-invalid {
  background: #170a0d;
}

.fv-12 {
  position: relative;
}

.fv-12__stage {
  padding-block-start: 4.75rem;
}

.fv-12__theme {
  position: absolute;
  inset-block-start: 0.875rem;
  inset-inline-end: 0.875rem;
  z-index: 6;
  display: flex;
  align-items: center;
  gap: 0.125rem;
  margin: 0;
  padding: 0.1875rem;
  border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  font-family: system-ui, sans-serif;
}

.fv-12__themelegend {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.fv-12__themeinput {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.fv-12__themeopt {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-block-size: 1.875rem;
  padding: 0 0.6875rem;
  border-radius: 999px;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: inherit;
  opacity: 0.55;
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}

.fv-12__themeopt:hover {
  opacity: 0.85;
}

.fv-12__themeinput:checked + .fv-12__themeopt {
  opacity: 1;
  background: color-mix(in srgb, currentColor 15%, transparent);
}

.fv-12__themeinput:focus-visible + .fv-12__themeopt {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

@media (forced-colors: active) {
  .fv-12__theme {
    border-color: CanvasText;
    background: Canvas;
  }

  .fv-12__themeinput:checked + .fv-12__themeopt {
    background: Highlight;
    color: HighlightText;
  }
}

.fv-12:has(#fv-12-theme-light:checked) {
  --page: #eef0f4;
  --surface: rgba(255, 255, 255, 0.78);
  --field: #ffffff;
  --ink: #14161c;
  --muted: #5c6472;
  --rule: #d6dae2;
  --accent: #0b8f84;
  --error: #cf2137;
  --error-ink: #a5162a;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__stage {
  background-image: radial-gradient(90% 60% at 20% 0%, rgba(11, 143, 132, 0.1) 0%, transparent 60%), radial-gradient(70% 50% at 100% 100%, rgba(207, 33, 55, 0.08) 0%, transparent 60%);
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__summary {
  background: rgba(207, 33, 55, 0.07);
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__input:user-invalid {
  background: #fdf2f3;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__input::placeholder {
  color: #9aa2b0;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__list a:hover {
  color: #14161c;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__btn {
  color: #ffffff;
}
(() => {
  const form = document.querySelector('.fv-12__form');
  if (!form || form.dataset.fv12) return;
  form.dataset.fv12 = '1';
  const list = form.querySelector('.fv-12__list');
  const count = form.querySelector('.fv-12__count');
  const fields = [...form.querySelectorAll('.fv-12__input')];
  const label = (f) => form.querySelector('label[for="' + f.id + '"]').textContent;
  const sync = () => {
    const bad = fields.filter((f) => !f.checkValidity());
    count.textContent = bad.length === 1 ? '1 field needs attention' : bad.length + ' fields need attention';
    list.replaceChildren(...bad.map((f) => {
      const li = document.createElement('li');
      const a = document.createElement('a');
      a.href = '#' + f.id;
      a.textContent = label(f) + ' — ' + (f.validationMessage || 'Check this field');
      a.addEventListener('click', (e) => { e.preventDefault(); f.focus(); });
      li.append(a);
      return li;
    }));
  };
  fields.forEach((f) => { f.addEventListener('blur', sync); f.addEventListener('input', sync); });
  form.querySelector('.fv-12__btn').addEventListener('click', () => { sync(); if (!form.checkValidity()) form.reportValidity(); });
  fields.forEach((f) => f.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sync(); form.reportValidity(); } }));
  sync();
})();
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 Form Validation 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: Validation Summary Box with Has Selector
Source: https://codefronts.com/components/css-form-validation/validation-summary-box-with-has-selector/

A summary panel above the form that appears only while the form contains a field the user has left invalid — .fv-12__form:has(:user-invalid) .fv-12__summary, and nothing else. Each line links to its field by id so clicking moves focus straight there. The script only keeps the list text current; whether the panel is on screen is decided entirely in CSS, and that split is the point.
## HTML
```html
<section class="fv-12" aria-labelledby="fv-12-h">
  <fieldset class="fv-12__theme">
    <legend class="fv-12__themelegend">Theme</legend>
    <input class="fv-12__themeinput" type="radio" name="fv-12-theme" id="fv-12-theme-auto" checked>
    <label class="fv-12__themeopt" for="fv-12-theme-auto">Auto</label>
    <input class="fv-12__themeinput" type="radio" name="fv-12-theme" id="fv-12-theme-light">
    <label class="fv-12__themeopt" for="fv-12-theme-light">Light</label>
    <input class="fv-12__themeinput" type="radio" name="fv-12-theme" id="fv-12-theme-dark">
    <label class="fv-12__themeopt" for="fv-12-theme-dark">Dark</label>
  </fieldset>
  <div class="fv-12__stage">
    <form class="fv-12__form" aria-labelledby="fv-12-h">
      <header class="fv-12__head">
        <p class="fv-12__eyebrow">Sable · access request</p>
        <h2 class="fv-12__h" id="fv-12-h">The panel is a selector, not a state flag</h2>
      </header>

      <div class="fv-12__summary" role="group" aria-labelledby="fv-12-sum-h">
        <div class="fv-12__sumhead">
          <svg class="fv-12__sumicon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle cx="10" cy="10" r="8.2" fill="none" stroke="currentColor" stroke-width="1.7"></circle><path d="M10 5.6v5.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path><circle cx="10" cy="14.3" r="1.1" fill="currentColor"></circle></svg>
          <h3 class="fv-12__sumtitle" id="fv-12-sum-h">Before this can be sent</h3>
          <p class="fv-12__count">2 fields need attention</p>
        </div>
        <ul class="fv-12__list">
          <li><a href="#fv-12-email">Work email — Enter a valid email address</a></li>
          <li><a href="#fv-12-seats">Seats — Enter a number from 1 to 500</a></li>
        </ul>
      </div>

      <div class="fv-12__fields">
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-name">Requester</label>
          <input class="fv-12__input" id="fv-12-name" name="fv-12-name" type="text" required autocomplete="name" placeholder="Jordan Lee" value="Jordan Lee">
        </div>
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-email">Work email</label>
          <input class="fv-12__input" id="fv-12-email" name="fv-12-email" type="email" required autocomplete="email" placeholder="jordan@sable.io" value="jordan.sable.io">
        </div>
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-seats">Seats</label>
          <input class="fv-12__input" id="fv-12-seats" name="fv-12-seats" type="number" required min="1" max="500" placeholder="24" value="0">
        </div>
        <div class="fv-12__field">
          <label class="fv-12__label" for="fv-12-team">Team</label>
          <input class="fv-12__input" id="fv-12-team" name="fv-12-team" type="text" required placeholder="Platform" value="Platform">
        </div>
      </div>

      <div class="fv-12__foot">
        <button class="fv-12__btn" type="button">Send request</button>
        <p class="fv-12__hint">Visibility is pure <code>:has()</code>. The list text is the only thing script touches.</p>
      </div>
    </form>
  </div>
</section>
```
## CSS
```css
.fv-12 {
  --page: #000000;
  --surface: rgba(20, 22, 26, 0.72);
  --field: #0c0d10;
  --ink: #f4f6fa;
  --muted: #9096a4;
  --rule: #23262e;
  --accent: #22e0d0;
  --error: #ff4b5c;
  --error-ink: #ff8f9a;
  --glass-blur: 14px;
  --font-display: "Geist", "Inter Tight", system-ui, sans-serif;
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, sans-serif;
}

@supports (color: oklch(0 0 0)) {
  .fv-12 {
    --accent: oklch(0.83 0.16 182);
    --error: oklch(0.65 0.22 18);
    --error-ink: oklch(0.76 0.16 18);
  }
}

.fv-12 *,
.fv-12 *::before,
.fv-12 *::after {
  box-sizing: border-box;
}

.fv-12 [hidden] {
  display: none;
}

.fv-12__stage {
  display: grid;
  place-items: center;
  min-block-size: 100vh;
  max-inline-size: 100%;
  padding: clamp(1.25rem, 4vw, 3.5rem) clamp(1rem, 4vw, 2.5rem);
  background-image: radial-gradient(90% 60% at 20% 0%, rgba(34, 224, 208, 0.12) 0%, transparent 60%), radial-gradient(70% 50% at 100% 100%, rgba(255, 75, 92, 0.1) 0%, transparent 60%);
}

.fv-12__form {
  inline-size: min(100%, 34rem);
  min-inline-size: 0;
  display: grid;
  gap: 1.125rem;
  padding: clamp(1.125rem, 3.5vw, 1.75rem);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 1rem;
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  backdrop-filter: blur(var(--glass-blur)) saturate(140%);
}

.fv-12__head {
  min-inline-size: 0;
}

.fv-12__eyebrow {
  margin: 0 0 0.375rem;
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
}

.fv-12__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.2rem, 3.4vw, 1.5rem);
  font-weight: 620;
  line-height: 1.2;
  letter-spacing: -0.025em;
}

.fv-12__summary {
  display: none;
  gap: 0.6875rem;
  min-inline-size: 0;
  padding: 0.9375rem 1rem;
  border: 1px solid var(--error);
  border-radius: 0.75rem;
  background: rgba(255, 75, 92, 0.1);
}

.fv-12__form:has(.fv-12__input:invalid:not(:placeholder-shown)) .fv-12__summary {
  display: grid;
}

.fv-12__form:has(.fv-12__input:user-invalid) .fv-12__summary {
  display: grid;
}

.fv-12__sumhead {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 0.5rem 0.625rem;
  min-inline-size: 0;
}

.fv-12__sumicon {
  grid-row: span 2;
  inline-size: 1.375rem;
  block-size: 1.375rem;
  color: var(--error);
}

.fv-12__sumtitle {
  margin: 0;
  min-inline-size: 0;
  font-family: var(--font-display);
  font-size: 0.9375rem;
  font-weight: 620;
  color: var(--error-ink);
}

.fv-12__count {
  margin: 0;
  min-inline-size: 0;
  font-size: 0.75rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.fv-12__list {
  margin: 0;
  padding-inline-start: 1.75rem;
  display: grid;
  gap: 0.375rem;
  min-inline-size: 0;
}

.fv-12__list li {
  min-inline-size: 0;
  font-size: 0.8125rem;
  line-height: 1.45;
}

.fv-12__list a {
  color: var(--error-ink);
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
}

.fv-12__list a:hover {
  color: #ffffff;
}

.fv-12__list a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 0.125rem;
}

.fv-12__fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
  gap: 0.875rem;
  min-inline-size: 0;
}

.fv-12__field {
  display: grid;
  gap: 0.375rem;
  min-inline-size: 0;
}

.fv-12__label {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}

.fv-12__input {
  min-inline-size: 0;
  inline-size: 100%;
  block-size: 2.75rem;
  padding: 0 0.8125rem;
  font: inherit;
  font-size: 0.9375rem;
  color: var(--ink);
  background: var(--field);
  border: 1px solid var(--rule);
  border-radius: 0.5rem;
}

.fv-12__input::placeholder {
  color: #5f6674;
}

.fv-12__input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.fv-12__input:invalid:not(:placeholder-shown):not(:focus) {
  border-color: var(--error);
}

.fv-12__input:user-invalid {
  border-color: var(--error);
  background: #170a0d;
}

.fv-12__input:user-valid {
  border-color: #2f6b62;
}

.fv-12__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem 1rem;
  min-inline-size: 0;
}

.fv-12__btn {
  min-block-size: 2.75rem;
  min-inline-size: 44px;
  padding: 0.6875rem 1.375rem;
  font: inherit;
  font-size: 0.9375rem;
  font-weight: 650;
  color: #04100f;
  background: var(--accent);
  border: 0;
  border-radius: 0.5rem;
  cursor: pointer;
}

.fv-12__btn:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 3px;
}

.fv-12__hint {
  margin: 0;
  min-inline-size: 0;
  font-size: 0.75rem;
  line-height: 1.45;
  color: var(--muted);
}

.fv-12__hint code {
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 0.9em;
  color: var(--accent);
}

@media (prefers-color-scheme: light) {
  .fv-12:not(:has(#fv-12-theme-dark:checked)) {
    --page: #eef0f4;
    --surface: rgba(255, 255, 255, 0.78);
    --field: #ffffff;
    --ink: #14161c;
    --muted: #5c6472;
    --rule: #d6dae2;
    --accent: #0b8f84;
    --error: #cf2137;
    --error-ink: #a5162a;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__stage {
    background-image: radial-gradient(90% 60% at 20% 0%, rgba(11, 143, 132, 0.1) 0%, transparent 60%), radial-gradient(70% 50% at 100% 100%, rgba(207, 33, 55, 0.08) 0%, transparent 60%);
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__summary {
    background: rgba(207, 33, 55, 0.07);
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__input:user-invalid {
    background: #fdf2f3;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__input::placeholder {
    color: #9aa2b0;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__list a:hover {
    color: #14161c;
  }

  .fv-12:not(:has(#fv-12-theme-dark:checked)) .fv-12__btn {
    color: #ffffff;
  }
}

[data-theme="dark"] .fv-12:not(:has(#fv-12-theme-light:checked)) {
  --page: #000000;
  --surface: rgba(20, 22, 26, 0.72);
  --field: #0c0d10;
  --ink: #f4f6fa;
  --muted: #9096a4;
  --rule: #23262e;
  --accent: #22e0d0;
  --error: #ff4b5c;
  --error-ink: #ff8f9a;
}

[data-theme="dark"] .fv-12:not(:has(#fv-12-theme-light:checked)) .fv-12__btn {
  color: #04100f;
}

[data-theme="dark"] .fv-12:not(:has(#fv-12-theme-light:checked)) .fv-12__input:user-invalid {
  background: #170a0d;
}

.fv-12 {
  position: relative;
}

.fv-12__stage {
  padding-block-start: 4.75rem;
}

.fv-12__theme {
  position: absolute;
  inset-block-start: 0.875rem;
  inset-inline-end: 0.875rem;
  z-index: 6;
  display: flex;
  align-items: center;
  gap: 0.125rem;
  margin: 0;
  padding: 0.1875rem;
  border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  font-family: system-ui, sans-serif;
}

.fv-12__themelegend {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.fv-12__themeinput {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.fv-12__themeopt {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-block-size: 1.875rem;
  padding: 0 0.6875rem;
  border-radius: 999px;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: inherit;
  opacity: 0.55;
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}

.fv-12__themeopt:hover {
  opacity: 0.85;
}

.fv-12__themeinput:checked + .fv-12__themeopt {
  opacity: 1;
  background: color-mix(in srgb, currentColor 15%, transparent);
}

.fv-12__themeinput:focus-visible + .fv-12__themeopt {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

@media (forced-colors: active) {
  .fv-12__theme {
    border-color: CanvasText;
    background: Canvas;
  }

  .fv-12__themeinput:checked + .fv-12__themeopt {
    background: Highlight;
    color: HighlightText;
  }
}

.fv-12:has(#fv-12-theme-light:checked) {
  --page: #eef0f4;
  --surface: rgba(255, 255, 255, 0.78);
  --field: #ffffff;
  --ink: #14161c;
  --muted: #5c6472;
  --rule: #d6dae2;
  --accent: #0b8f84;
  --error: #cf2137;
  --error-ink: #a5162a;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__stage {
  background-image: radial-gradient(90% 60% at 20% 0%, rgba(11, 143, 132, 0.1) 0%, transparent 60%), radial-gradient(70% 50% at 100% 100%, rgba(207, 33, 55, 0.08) 0%, transparent 60%);
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__summary {
  background: rgba(207, 33, 55, 0.07);
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__input:user-invalid {
  background: #fdf2f3;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__input::placeholder {
  color: #9aa2b0;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__list a:hover {
  color: #14161c;
}

.fv-12:has(#fv-12-theme-light:checked) .fv-12__btn {
  color: #ffffff;
}
```

## JavaScript
```js
(() => {
  const form = document.querySelector('.fv-12__form');
  if (!form || form.dataset.fv12) return;
  form.dataset.fv12 = '1';
  const list = form.querySelector('.fv-12__list');
  const count = form.querySelector('.fv-12__count');
  const fields = [...form.querySelectorAll('.fv-12__input')];
  const label = (f) => form.querySelector('label[for="' + f.id + '"]').textContent;
  const sync = () => {
    const bad = fields.filter((f) => !f.checkValidity());
    count.textContent = bad.length === 1 ? '1 field needs attention' : bad.length + ' fields need attention';
    list.replaceChildren(...bad.map((f) => {
      const li = document.createElement('li');
      const a = document.createElement('a');
      a.href = '#' + f.id;
      a.textContent = label(f) + ' — ' + (f.validationMessage || 'Check this field');
      a.addEventListener('click', (e) => { e.preventDefault(); f.focus(); });
      li.append(a);
      return li;
    }));
  };
  fields.forEach((f) => { f.addEventListener('blur', sync); f.addEventListener('input', sync); });
  form.querySelector('.fv-12__btn').addEventListener('click', () => { sync(); if (!form.checkValidity()) form.reportValidity(); });
  fields.forEach((f) => f.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sync(); form.reportValidity(); } }));
  sync();
})();
```

How this works

The panel's visibility is a selector, not a state variable. :has() lets the form ask a question about its own descendants: does anything inside me match :user-invalid. If yes, the summary is displayed; if no, it is not. There is no isVisible flag to get out of sync, no transition left half-applied, and the panel cannot be shown when the form is actually clean.

The script's only job is the words. It reads each failing field's validationMessage — the browser's own sentence, or the one you set with setCustomValidity() — and rebuilds the list. Keeping content and visibility in different layers means a JavaScript error can leave the list stale but can never leave the panel wrongly open, which is the failure that makes summary boxes untrustworthy.

Each line must be a real link. An error summary that names five problems and offers no route to them makes the user hunt. Anchors pointing at #fv-12-… ids give keyboard and screen-reader users a way through the list, and a click handler calls focus() so the caret lands in the field rather than just scrolling it into view.

The trap: an empty summary that is still on screen. If you drive visibility from script and forget one exit path — the user fixes the last field by pasting, say — the panel stays with an empty list, which reads as a broken form. The :has() version cannot do this. The [hidden] display-none guard covers the other direction: an author display: grid on the panel would otherwise override the attribute.

Make it yours

  • Change the summary heading copy — the count line beside it is generated from the field list.
  • Add a field and the summary picks it up automatically; only the shared input class matters.
  • Swap :user-invalid for :invalid in the :has() rule if you want the panel present from load (rarely what you want).
  • Retint by changing --accent and --error; the glass tint derives from both.
  • Remove backdrop-filter for a solid panel on low-powered devices.
  • Set --glass-blur to 0px to keep the layout and drop the blur alone.

Gotchas — read before shipping

  • Driving the panel's visibility from script eventually leaves it open with an empty list — the state and the DOM drift apart.
  • A summary that names errors without linking to the fields forces the user to search the form manually.
  • Without the [hidden] display-none guard, an author display: grid on the panel overrides the attribute and the panel never hides.
  • :has() only shipped in Firefox 121, so on older Firefox the panel never appears — keep the per-field messages as the primary channel.

Browser support

ChromeSafariFirefoxEdge
119+16.5+121+119+

Firefox's floor is set by :has(), which shipped in 121 and is what makes the panel appear at all; Chrome 119 and Safari 16.5 are set by :user-invalid inside that selector. backdrop-filter ships prefixed and unprefixed. Below the Firefox floor the summary never displays and the inline per-field messages carry the errors on their own — degraded, but not broken.

Techniques used in this demo

Search CodeFronts

Loading…