20 CSS Form Validation19 / 20

Pure CSSMIT licensed

Dark Mode Validation Colour Tokens

The error red that clears 4.5:1 on white usually fails on a dark ground — same hue, half the contrast. This demo defines four semantic tokens (--fv-error, --fv-error-bg, --fv-success, --fv-warning), swaps them under prefers-color-scheme: dark and a [data-theme="dark"] override, and prints the measured contrast ratio next to every swatch so the change is auditable rather than assumed.

Published

Live Demo
Try it

The code

<section class="fv-19" aria-labelledby="fv-19-h">
  <div class="fv-19__stage">
    <div class="fv-19__panel">
      <header class="fv-19__head">
        <div class="fv-19__headtext">
          <p class="fv-19__eyebrow">Prism design tokens</p>
          <h2 class="fv-19__h" id="fv-19-h">Same meaning, different luminance</h2>
        </div>
        <div class="fv-19__toggle">
          <input class="fv-19__switch" type="checkbox" id="fv-19-theme">
          <label class="fv-19__switchlabel" for="fv-19-theme">
            <span class="fv-19__track" aria-hidden="true"><span class="fv-19__knob"></span></span>
            Invert theme
          </label>
        </div>
      </header>

      <div class="fv-19__tokens">
        <div class="fv-19__token" data-token="error">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-error</code>
          <span class="fv-19__ratio">4.8:1 on surface</span>
        </div>
        <div class="fv-19__token" data-token="errorbg">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-error-bg</code>
          <span class="fv-19__ratio">1.1:1 · tint only</span>
        </div>
        <div class="fv-19__token" data-token="success">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-success</code>
          <span class="fv-19__ratio">4.6:1 on surface</span>
        </div>
        <div class="fv-19__token" data-token="warning">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-warning</code>
          <span class="fv-19__ratio">4.5:1 on surface</span>
        </div>
      </div>

      <form class="fv-19__form" aria-labelledby="fv-19-h">
        <div class="fv-19__field">
          <label class="fv-19__label" for="fv-19-domain">Custom domain</label>
          <input class="fv-19__input" id="fv-19-domain" name="fv-19-domain" type="text" required pattern="[a-z0-9.-]+\.[a-z]{2,}" placeholder="status.prism.io" value="status.prism" aria-describedby="fv-19-domain-msg">
          <p class="fv-19__msg" id="fv-19-domain-msg">
            <svg class="fv-19__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="1.7"></circle><path d="M10 5.9v5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path><circle cx="10" cy="14.2" r="1.05" fill="currentColor"></circle></svg>
            <span>Include a top-level domain, like status.prism.io</span>
          </p>
        </div>

        <div class="fv-19__field">
          <label class="fv-19__label" for="fv-19-ttl">Cache TTL (seconds)</label>
          <input class="fv-19__input" id="fv-19-ttl" name="fv-19-ttl" type="number" required min="30" max="86400" placeholder="300" value="300" aria-describedby="fv-19-ttl-note">
          <p class="fv-19__note" id="fv-19-ttl-note">Valid state uses <code>--fv-success</code> for the border only — no green fill.</p>
        </div>
      </form>

      <p class="fv-19__foot">The toggle inverts whatever your system asked for, in CSS alone, via mirror rules in both <code>prefers-color-scheme</code> blocks.</p>
    </div>
  </div>
</section>
.fv-19 {
  --page: #f5f5f4;
  --surface: #ffffff;
  --field: #fafaf9;
  --ink: #1c1c1a;
  --muted: #5d5d58;
  --rule: #d8d8d4;
  --fv-error: #c02434;
  --fv-error-bg: #fdf1f2;
  --fv-success: #1c7a52;
  --fv-warning: #8a5a05;
  --radius: 0.4375rem;
  --font-display: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
  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-19 {
    --fv-error: oklch(0.51 0.2 18);
    --fv-success: oklch(0.47 0.12 160);
    --fv-warning: oklch(0.5 0.11 75);
  }
}

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

.fv-19__stage {
  display: grid;
  place-items: center;
  min-block-size: 100vh;
  max-inline-size: 100%;
  padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.5rem);
}

.fv-19__panel {
  inline-size: min(100%, 34rem);
  min-inline-size: 0;
  display: grid;
  gap: 1.125rem;
  padding: clamp(1.25rem, 4vw, 1.75rem);
  background: var(--surface);
  border-radius: 0.875rem;
}

.fv-19__head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.875rem;
  min-inline-size: 0;
}

.fv-19__headtext {
  min-inline-size: 0;
}

.fv-19__eyebrow {
  margin: 0 0 0.375rem;
  font-family: var(--font-display);
  font-size: 0.6875rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.fv-19__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.15rem, 3.2vw, 1.45rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.2;
}

.fv-19__switch {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.fv-19__switchlabel {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-block-size: 2.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  cursor: pointer;
}

.fv-19__track {
  display: block;
  inline-size: 2.375rem;
  block-size: 1.375rem;
  padding: 2px;
  border-radius: 999px;
  background: var(--rule);
}

.fv-19__knob {
  display: block;
  inline-size: 1rem;
  block-size: 1rem;
  border-radius: 50%;
  background: var(--surface);
  transition: margin 0.18s ease;
}

.fv-19__switch:checked + .fv-19__switchlabel .fv-19__track {
  background: var(--ink);
}

.fv-19__switch:checked + .fv-19__switchlabel .fv-19__knob {
  margin-inline-start: 1rem;
}

.fv-19__switch:focus-visible + .fv-19__switchlabel .fv-19__track {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

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

.fv-19__token {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 0.25rem 0.625rem;
  min-inline-size: 0;
  padding: 0.625rem 0.75rem;
  background: var(--field);
  border-radius: var(--radius);
}

.fv-19__chip {
  grid-row: span 2;
  inline-size: 1.75rem;
  block-size: 1.75rem;
  border-radius: 0.25rem;
}

.fv-19__token[data-token="error"] .fv-19__chip {
  background: var(--fv-error);
}

.fv-19__token[data-token="errorbg"] .fv-19__chip {
  background: var(--fv-error-bg);
  box-shadow: inset 0 0 0 1px var(--rule);
}

.fv-19__token[data-token="success"] .fv-19__chip {
  background: var(--fv-success);
}

.fv-19__token[data-token="warning"] .fv-19__chip {
  background: var(--fv-warning);
}

.fv-19__name {
  min-inline-size: 0;
  font-family: var(--font-display);
  font-size: 0.75rem;
  overflow-wrap: anywhere;
}

.fv-19__ratio {
  min-inline-size: 0;
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.fv-19__form {
  display: grid;
  gap: 0.875rem;
  min-inline-size: 0;
}

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

.fv-19__label {
  font-size: 0.8125rem;
  font-weight: 600;
}

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

.fv-19__input::placeholder {
  color: #a3a39d;
}

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

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

.fv-19__input:invalid:not(:placeholder-shown):not(:focus) + .fv-19__msg {
  display: flex;
}

.fv-19__input:user-invalid {
  border-color: var(--fv-error);
  background: var(--fv-error-bg);
}

.fv-19__input:user-invalid + .fv-19__msg {
  display: flex;
}

.fv-19__input:user-valid {
  border-color: var(--fv-success);
}

.fv-19__msg {
  display: none;
  gap: 0.375rem;
  align-items: flex-start;
  margin: 0;
  min-inline-size: 0;
  font-family: system-ui, sans-serif;
  font-size: 0.8125rem;
  line-height: 1.45;
  color: var(--fv-error);
}

.fv-19__msg span {
  min-inline-size: 0;
}

.fv-19__icon {
  flex: none;
  inline-size: 1rem;
  block-size: 1rem;
  margin-block-start: 0.1rem;
}

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

.fv-19__note code,
.fv-19__foot code {
  font-family: var(--font-display);
  font-size: 0.92em;
}

.fv-19__foot {
  margin: 0;
  min-inline-size: 0;
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--muted);
}

@media (prefers-reduced-motion: reduce) {
  .fv-19__knob {
    transition: none;
  }
}

.fv-19:has(.fv-19__switch:checked) {
  --page: #0d0d0c;
  --surface: #161615;
  --field: #111110;
  --ink: #f1f1ef;
  --muted: #a1a19a;
  --rule: #2c2c2a;
  --fv-error: #ff8496;
  --fv-error-bg: #2a1218;
  --fv-success: #62cf9b;
  --fv-warning: #e0b055;
}

@media (prefers-color-scheme: dark) {
  .fv-19 {
    --page: #0d0d0c;
    --surface: #161615;
    --field: #111110;
    --ink: #f1f1ef;
    --muted: #a1a19a;
    --rule: #2c2c2a;
    --fv-error: #ff8496;
    --fv-error-bg: #2a1218;
    --fv-success: #62cf9b;
    --fv-warning: #e0b055;
  }

  .fv-19__input::placeholder {
    color: #6a6a64;
  }

  .fv-19:has(.fv-19__switch:checked) {
    --page: #f5f5f4;
    --surface: #ffffff;
    --field: #fafaf9;
    --ink: #1c1c1a;
    --muted: #5d5d58;
    --rule: #d8d8d4;
    --fv-error: #c02434;
    --fv-error-bg: #fdf1f2;
    --fv-success: #1c7a52;
    --fv-warning: #8a5a05;
  }
}

@media (prefers-color-scheme: light) {
  .fv-19:has(.fv-19__switch:checked) {
    --page: #0d0d0c;
    --surface: #161615;
    --field: #111110;
    --ink: #f1f1ef;
    --muted: #a1a19a;
    --rule: #2c2c2a;
    --fv-error: #ff8496;
    --fv-error-bg: #2a1218;
    --fv-success: #62cf9b;
    --fv-warning: #e0b055;
  }
}

[data-theme="dark"] .fv-19 {
  --page: #0d0d0c;
  --surface: #161615;
  --field: #111110;
  --ink: #f1f1ef;
  --muted: #a1a19a;
  --rule: #2c2c2a;
  --fv-error: #ff8496;
  --fv-error-bg: #2a1218;
  --fv-success: #62cf9b;
  --fv-warning: #e0b055;
}
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: Dark Mode Validation Colour Tokens
Source: https://codefronts.com/components/css-form-validation/dark-mode-validation-colour-tokens/

The error red that clears 4.5:1 on white usually fails on a dark ground — same hue, half the contrast. This demo defines four semantic tokens (--fv-error, --fv-error-bg, --fv-success, --fv-warning), swaps them under prefers-color-scheme: dark and a [data-theme="dark"] override, and prints the measured contrast ratio next to every swatch so the change is auditable rather than assumed.
## HTML
```html
<section class="fv-19" aria-labelledby="fv-19-h">
  <div class="fv-19__stage">
    <div class="fv-19__panel">
      <header class="fv-19__head">
        <div class="fv-19__headtext">
          <p class="fv-19__eyebrow">Prism design tokens</p>
          <h2 class="fv-19__h" id="fv-19-h">Same meaning, different luminance</h2>
        </div>
        <div class="fv-19__toggle">
          <input class="fv-19__switch" type="checkbox" id="fv-19-theme">
          <label class="fv-19__switchlabel" for="fv-19-theme">
            <span class="fv-19__track" aria-hidden="true"><span class="fv-19__knob"></span></span>
            Invert theme
          </label>
        </div>
      </header>

      <div class="fv-19__tokens">
        <div class="fv-19__token" data-token="error">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-error</code>
          <span class="fv-19__ratio">4.8:1 on surface</span>
        </div>
        <div class="fv-19__token" data-token="errorbg">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-error-bg</code>
          <span class="fv-19__ratio">1.1:1 · tint only</span>
        </div>
        <div class="fv-19__token" data-token="success">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-success</code>
          <span class="fv-19__ratio">4.6:1 on surface</span>
        </div>
        <div class="fv-19__token" data-token="warning">
          <span class="fv-19__chip" aria-hidden="true"></span>
          <code class="fv-19__name">--fv-warning</code>
          <span class="fv-19__ratio">4.5:1 on surface</span>
        </div>
      </div>

      <form class="fv-19__form" aria-labelledby="fv-19-h">
        <div class="fv-19__field">
          <label class="fv-19__label" for="fv-19-domain">Custom domain</label>
          <input class="fv-19__input" id="fv-19-domain" name="fv-19-domain" type="text" required pattern="[a-z0-9.-]+\.[a-z]{2,}" placeholder="status.prism.io" value="status.prism" aria-describedby="fv-19-domain-msg">
          <p class="fv-19__msg" id="fv-19-domain-msg">
            <svg class="fv-19__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="1.7"></circle><path d="M10 5.9v5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path><circle cx="10" cy="14.2" r="1.05" fill="currentColor"></circle></svg>
            <span>Include a top-level domain, like status.prism.io</span>
          </p>
        </div>

        <div class="fv-19__field">
          <label class="fv-19__label" for="fv-19-ttl">Cache TTL (seconds)</label>
          <input class="fv-19__input" id="fv-19-ttl" name="fv-19-ttl" type="number" required min="30" max="86400" placeholder="300" value="300" aria-describedby="fv-19-ttl-note">
          <p class="fv-19__note" id="fv-19-ttl-note">Valid state uses <code>--fv-success</code> for the border only — no green fill.</p>
        </div>
      </form>

      <p class="fv-19__foot">The toggle inverts whatever your system asked for, in CSS alone, via mirror rules in both <code>prefers-color-scheme</code> blocks.</p>
    </div>
  </div>
</section>
```
## CSS
```css
.fv-19 {
  --page: #f5f5f4;
  --surface: #ffffff;
  --field: #fafaf9;
  --ink: #1c1c1a;
  --muted: #5d5d58;
  --rule: #d8d8d4;
  --fv-error: #c02434;
  --fv-error-bg: #fdf1f2;
  --fv-success: #1c7a52;
  --fv-warning: #8a5a05;
  --radius: 0.4375rem;
  --font-display: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
  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-19 {
    --fv-error: oklch(0.51 0.2 18);
    --fv-success: oklch(0.47 0.12 160);
    --fv-warning: oklch(0.5 0.11 75);
  }
}

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

.fv-19__stage {
  display: grid;
  place-items: center;
  min-block-size: 100vh;
  max-inline-size: 100%;
  padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.5rem);
}

.fv-19__panel {
  inline-size: min(100%, 34rem);
  min-inline-size: 0;
  display: grid;
  gap: 1.125rem;
  padding: clamp(1.25rem, 4vw, 1.75rem);
  background: var(--surface);
  border-radius: 0.875rem;
}

.fv-19__head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.875rem;
  min-inline-size: 0;
}

.fv-19__headtext {
  min-inline-size: 0;
}

.fv-19__eyebrow {
  margin: 0 0 0.375rem;
  font-family: var(--font-display);
  font-size: 0.6875rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.fv-19__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.15rem, 3.2vw, 1.45rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.2;
}

.fv-19__switch {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.fv-19__switchlabel {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-block-size: 2.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  cursor: pointer;
}

.fv-19__track {
  display: block;
  inline-size: 2.375rem;
  block-size: 1.375rem;
  padding: 2px;
  border-radius: 999px;
  background: var(--rule);
}

.fv-19__knob {
  display: block;
  inline-size: 1rem;
  block-size: 1rem;
  border-radius: 50%;
  background: var(--surface);
  transition: margin 0.18s ease;
}

.fv-19__switch:checked + .fv-19__switchlabel .fv-19__track {
  background: var(--ink);
}

.fv-19__switch:checked + .fv-19__switchlabel .fv-19__knob {
  margin-inline-start: 1rem;
}

.fv-19__switch:focus-visible + .fv-19__switchlabel .fv-19__track {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

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

.fv-19__token {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 0.25rem 0.625rem;
  min-inline-size: 0;
  padding: 0.625rem 0.75rem;
  background: var(--field);
  border-radius: var(--radius);
}

.fv-19__chip {
  grid-row: span 2;
  inline-size: 1.75rem;
  block-size: 1.75rem;
  border-radius: 0.25rem;
}

.fv-19__token[data-token="error"] .fv-19__chip {
  background: var(--fv-error);
}

.fv-19__token[data-token="errorbg"] .fv-19__chip {
  background: var(--fv-error-bg);
  box-shadow: inset 0 0 0 1px var(--rule);
}

.fv-19__token[data-token="success"] .fv-19__chip {
  background: var(--fv-success);
}

.fv-19__token[data-token="warning"] .fv-19__chip {
  background: var(--fv-warning);
}

.fv-19__name {
  min-inline-size: 0;
  font-family: var(--font-display);
  font-size: 0.75rem;
  overflow-wrap: anywhere;
}

.fv-19__ratio {
  min-inline-size: 0;
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.fv-19__form {
  display: grid;
  gap: 0.875rem;
  min-inline-size: 0;
}

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

.fv-19__label {
  font-size: 0.8125rem;
  font-weight: 600;
}

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

.fv-19__input::placeholder {
  color: #a3a39d;
}

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

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

.fv-19__input:invalid:not(:placeholder-shown):not(:focus) + .fv-19__msg {
  display: flex;
}

.fv-19__input:user-invalid {
  border-color: var(--fv-error);
  background: var(--fv-error-bg);
}

.fv-19__input:user-invalid + .fv-19__msg {
  display: flex;
}

.fv-19__input:user-valid {
  border-color: var(--fv-success);
}

.fv-19__msg {
  display: none;
  gap: 0.375rem;
  align-items: flex-start;
  margin: 0;
  min-inline-size: 0;
  font-family: system-ui, sans-serif;
  font-size: 0.8125rem;
  line-height: 1.45;
  color: var(--fv-error);
}

.fv-19__msg span {
  min-inline-size: 0;
}

.fv-19__icon {
  flex: none;
  inline-size: 1rem;
  block-size: 1rem;
  margin-block-start: 0.1rem;
}

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

.fv-19__note code,
.fv-19__foot code {
  font-family: var(--font-display);
  font-size: 0.92em;
}

.fv-19__foot {
  margin: 0;
  min-inline-size: 0;
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--muted);
}

@media (prefers-reduced-motion: reduce) {
  .fv-19__knob {
    transition: none;
  }
}

.fv-19:has(.fv-19__switch:checked) {
  --page: #0d0d0c;
  --surface: #161615;
  --field: #111110;
  --ink: #f1f1ef;
  --muted: #a1a19a;
  --rule: #2c2c2a;
  --fv-error: #ff8496;
  --fv-error-bg: #2a1218;
  --fv-success: #62cf9b;
  --fv-warning: #e0b055;
}

@media (prefers-color-scheme: dark) {
  .fv-19 {
    --page: #0d0d0c;
    --surface: #161615;
    --field: #111110;
    --ink: #f1f1ef;
    --muted: #a1a19a;
    --rule: #2c2c2a;
    --fv-error: #ff8496;
    --fv-error-bg: #2a1218;
    --fv-success: #62cf9b;
    --fv-warning: #e0b055;
  }

  .fv-19__input::placeholder {
    color: #6a6a64;
  }

  .fv-19:has(.fv-19__switch:checked) {
    --page: #f5f5f4;
    --surface: #ffffff;
    --field: #fafaf9;
    --ink: #1c1c1a;
    --muted: #5d5d58;
    --rule: #d8d8d4;
    --fv-error: #c02434;
    --fv-error-bg: #fdf1f2;
    --fv-success: #1c7a52;
    --fv-warning: #8a5a05;
  }
}

@media (prefers-color-scheme: light) {
  .fv-19:has(.fv-19__switch:checked) {
    --page: #0d0d0c;
    --surface: #161615;
    --field: #111110;
    --ink: #f1f1ef;
    --muted: #a1a19a;
    --rule: #2c2c2a;
    --fv-error: #ff8496;
    --fv-error-bg: #2a1218;
    --fv-success: #62cf9b;
    --fv-warning: #e0b055;
  }
}

[data-theme="dark"] .fv-19 {
  --page: #0d0d0c;
  --surface: #161615;
  --field: #111110;
  --ink: #f1f1ef;
  --muted: #a1a19a;
  --rule: #2c2c2a;
  --fv-error: #ff8496;
  --fv-error-bg: #2a1218;
  --fv-success: #62cf9b;
  --fv-warning: #e0b055;
}
```

How this works

A hue that works on white rarely works on near-black. Contrast is a ratio between two luminances, so darkening the background changes the requirement, not just the appearance. The usual result is an error red that reads as brown-grey on a dark surface at around 3:1 — legible enough to look fine in review, and short of the 4.5:1 that body-size error text needs.

Name tokens for meaning, not for colour. --fv-error can become a lighter, less saturated red in dark mode without a single consumer changing, because nothing references --red-600. The set here is deliberately small: error, error background, success and warning, plus separate ink variants, since text and borders have different thresholds — 4.5:1 for text, 3:1 for the border and icon.

Ship both switches. prefers-color-scheme respects the system setting and costs nothing; a [data-theme="dark"] attribute lets a product honour an in-app preference. Define the dark values twice — once in the media query, once under the attribute — and any component that reads the tokens works in both worlds.

The trap: a toggle that only overrides one direction. If the checked state applies dark tokens and nothing flips them back, a user whose system is already dark gets a switch that does nothing. The fix is the mirror rule: inside @media (prefers-color-scheme: dark) the checked state must apply the light tokens, and inside @media (prefers-color-scheme: light) it applies the dark ones. The toggle then always inverts whatever the system said, with no JavaScript.

Make it yours

  • Change any single token on the root and every state that uses it follows in both themes.
  • Add a fifth token — an info blue, say — by copying one token line into all four blocks.
  • Adjust the annotated ratios if you retune a hue; they are plain text, not computed.
  • Remove the [data-theme] block if your product has no in-app theme control.
  • Set --radius to 0.75rem for softer fields without touching the tokens.
  • Swap the monospace display face via --font-display.

Gotchas — read before shipping

  • Reusing the light-mode error red on a dark background typically lands near 3:1, which fails 4.5:1 for error text.
  • A theme toggle without the mirror flip-back rule does nothing for users whose system preference already matches the toggled state.
  • Naming tokens after colours (--red-600) forces every consumer to change when the dark value differs.
  • Using one token for both error text and error borders means either the text fails 4.5:1 or the border is heavier than it needs to be.

Browser support

ChromeSafariFirefoxEdge
119+16.5+121+119+

Firefox's floor comes from :has(), used by the pure-CSS toggle (121); Chrome 119 and Safari 16.5 come from :user-invalid on the field states. Custom properties and prefers-color-scheme are Baseline. Without :has() the toggle stops working and the demo simply follows the system preference — the tokens, the ratios and every validation state still render correctly.

Techniques used in this demo

Search CodeFronts

Loading…