Showing Valid Field Checkmarks Accessibly

How do you show a green tick beside a field that passed verification — a username that is available, a password that meets every rule, a promo code that applies — in a way that colour-blind users can perceive, screen reader users hear once, and nobody mistakes for a verdict on a value they have since changed? This recipe implements an opt-in success indicator: text status linked with aria-describedby, a decorative icon, success colour that passes contrast, styling tied to :user-valid or a verification state, a single polite announcement when an asynchronous check succeeds, and immediate clearing when the value changes. Failures still flow through setCustomValidity() and reportValidity() from the Constraint Validation API; the checkmark is the positive counterpart.

When a Checkmark Helps

A checkmark is information only when it confirms something the user could not judge themselves. Show it for:

  • Server-verified values — username or handle availability, promo codes, gift cards, referral codes.
  • Multi-rule fields — passwords meeting a checklist, IBANs passing a checksum, card numbers passing Luhn.
  • Processed uploads — a file checked for type, size and dimensions and ready to submit.

Skip it for names, free text, simple selects and anything valid by default. The reasoning, and the wider design of positive feedback, is in the success states and positive feedback topic.

Should this field show a checkmark? A decision tree that decides whether a field should show a success checkmark based on whether the system verified something invisible to the user and whether the success could mislead. Did the system verify something the user cannot see? no No checkmark yes Could the tick be read as more than was checked? yes Reword the status, e.g. "Format looks right" no Show checkmark + text status
Checkmarks are for verified facts, not for "nothing wrong so far".

Minimal Working Accessible Checkmark

<div class="field" data-confirmable>
  <label for="promo">Promo code <span class="optional">(optional)</span></label>
  <input id="promo" name="promo" autocomplete="off" autocapitalize="characters"
         aria-describedby="promo-status promo-err">
  <p id="promo-status" class="field-status" data-state="idle"></p>
  <p id="promo-err" class="field-error" hidden></p>
</div>
<p id="form-live" class="visually-hidden" role="status"></p>
.field-status { display: flex; align-items: center; gap: 0.35rem; min-block-size: 1.4em; }
.field-status[data-state="ok"] { color: var(--success-text, #047857); }
.field-status[data-state="ok"]::before {
  content: "";
  inline-size: 1em; block-size: 1em;
  background: currentColor;                     /* survives forced colours via currentColor */
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 11.2 2.8 8l-1.1 1.1L6 13.4l8.3-8.3-1.1-1.1z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.field[data-confirmable]:has(.field-status[data-state="ok"]) input {
  border-color: var(--success-border, #059669);
}
:root[data-theme="dark"] { --success-text: #6ee7b7; --success-border: #34d399; }
const input = document.querySelector<HTMLInputElement>("#promo")!;
const status = document.querySelector<HTMLElement>("#promo-status")!;
const live = document.querySelector<HTMLElement>("#form-live")!;
let controller: AbortController | undefined;
let timer: number | undefined;
let checkedValue = "";

function setStatus(state: "idle" | "checking" | "ok", text = ""): void {
  status.dataset.state = state;
  status.textContent = text;
}

function announce(text: string): void {
  live.textContent = "";
  requestAnimationFrame(() => (live.textContent = text));
}

input.addEventListener("input", () => {
  controller?.abort();
  window.clearTimeout(timer);
  setStatus("idle");                                  // any edit invalidates the old verdict
  input.setCustomValidity("");
  const code = input.value.trim().toUpperCase();
  if (code.length < 4) return;
  timer = window.setTimeout(async () => {
    controller = new AbortController();
    setStatus("checking", "Checking code…");
    try {
      const res = await fetch(`/api/promo/${encodeURIComponent(code)}`, { signal: controller.signal });
      if (input.value.trim().toUpperCase() !== code) return;     // stale response
      if (res.ok) {
        const { description } = await res.json();
        checkedValue = code;
        setStatus("ok", `${code} applied: ${description}.`);
        announce(`Promo code ${code} applied.`);
      } else {
        setStatus("idle");
        input.setCustomValidity("That promo code isn't valid or has expired.");
      }
    } catch (e) {
      if ((e as DOMException).name !== "AbortError") setStatus("idle", "We couldn't check the code. It will be checked when you pay.");
    }
  }, 500);
});

input.form!.addEventListener("submit", (e) => {
  if (!input.form!.reportValidity()) e.preventDefault();
});

The status paragraph is always in aria-describedby, so when the user returns to the field their screen reader reads “PROMO10 applied: 10% off your order”. The live region speaks once, when the check succeeds — never while typing. And the very first thing any edit does is reset the status to idle, so a tick can never sit next to a value that was not the one checked.

Checkmark states for a verified field The field moves from idle to checking after a debounced edit, then to verified with a checkmark or to invalid with an error; any edit returns it to idle. idle checking verified ✓ invalid debounced input server confirms server rejects edit edit
The checkmark exists only in the verified state, and every edit leaves that state immediately.

Checkmark Option Reference

Option Type Default Purpose
data-confirmable attribute absent Opts a field into success styling
Status element <p> in aria-describedby empty Text that states what was verified
data-state idle / checking / ok idle Drives icon and colour
Icon CSS mask with currentColor Decorative; survives forced colours
Live announcement role="status" once per success Asynchronous results only
Success colours custom properties #047857 light / #6ee7b7 dark 4.5:1 text contrast in both themes
Debounce number (ms) 500 Before the verification request

Verification Steps

import { test, expect } from "@playwright/test";

test("checkmark clears as soon as the value changes", async ({ page }) => {
  await page.route("**/api/promo/*", (r) => r.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ description: "10% off your order" }) }));
  await page.goto("/checkout");
  const promo = page.getByLabel("Promo code (optional)");
  await promo.fill("PROMO10");
  await expect(page.locator("#promo-status")).toHaveText("PROMO10 applied: 10% off your order.");
  await promo.press("X");
  await expect(page.locator("#promo-status")).toHaveAttribute("data-state", "idle");
  await expect(page.locator("#promo-status")).toHaveText("");
});

Edge Cases and Failure Modes

Icons without text. A tick-only indicator fails WCAG 1.4.1 and says nothing to screen readers. Keep the text status; the icon is decoration.

Success colour contrast. Many brand greens fail 4.5:1 for text on white. Use a darker green for text and reserve lighter greens for non-text borders, which need 3:1.

Announcing on every keystroke. Updating a live region as the user types floods screen readers. Announce only the result of a completed check, and only when it changes.

Keeping the tick after a failed submit. If the server rejects the code at payment time (it expired between check and submit), clear the tick and show the error on the field, following mapping server field errors to form inputs.

Checkmarks Without Asynchronous Checks

Not every verified field needs a network request. A card number that passes its Luhn checksum, an IBAN that passes mod 97, or a file that passed type, size and dimension checks are all verified locally — and they deserve the same accessible treatment. The only difference is timing: show the checkmark on blur rather than after a debounce, because a checksum on a partially typed number is meaningless and a flickering tick while typing is distracting. Use :user-valid as the styling trigger for these fields, since the browser already knows when the user has interacted and whether the field is valid, and keep the text status as the carrier of meaning: “Card number looks right” is honest about what a checksum can tell you, while “Valid card” would promise more than the browser knows. The checksum patterns themselves are in Luhn algorithm credit card validation.

.field[data-confirmable="local"] input:user-valid { border-color: var(--success-border, #059669); }
.field[data-confirmable="local"]:has(input:user-valid) .field-status::after { content: "Card number looks right"; }

The CSS-generated text here is a convenience for sighted users only; set the same wording as real text in the status element from script on blur, so it is part of the field’s description for screen readers too.

Testing Checkmarks in All Themes

Include each confirmable field’s success state in visual regression tests for light mode, dark mode and forced colours, and run an automated contrast audit on the success text. Success styling is the part of a form most often forgotten when a theme changes, because it is only visible after a specific interaction.

Where to Put the Checkmark

Place the indicator where the user’s eye already is. Inside the input’s right edge works for short fields and survives narrow screens, but it must not overlap typed text — reserve padding for it — and it must not be the only signal. Beside the label keeps the input clean and associates the state with the field’s name. Below the input, in the same slot the error message uses, is the most consistent option: success and error occupy the same space and never appear together, which also prevents layout shift when the state changes.

Password and Checklist Fields

Password fields combine several success signals: individual rules turning to “Done”, a strength meter reaching “Strong”, and perhaps a breach check passing. Resist adding a separate field-level tick on top of these — the checklist already says, in text, exactly what has been met. If you do show an overall success, word it precisely (“Password meets all requirements”) and derive it from the same rule table as the checklist, so the two can never disagree. The checklist mechanics and their announcement strategy are covered in live password requirements checklist.

Checkmark done well versus done badly Two columns contrasting an accessible success indicator with a common inaccessible one. Accessible checkmark ✓ text names what was verified ✓ linked with aria-describedby ✓ announced once, politely ✓ cleared on the next edit Common mistake ✗ green border or icon only ✗ no text for screen readers ✗ stays after the value changes ✗ fails contrast in dark mode
The accessible version states what was verified in text, survives theme changes and disappears the moment the value changes.

Frequently Asked Questions

Is a green checkmark accessible on its own?

No. Colour and icons alone fail WCAG 1.4.1 and convey nothing to screen reader users. Pair the checkmark with text that states what was verified, linked to the field with aria-describedby.

Which fields should show a checkmark?

Fields where the system verified something the user cannot see: username availability, promo codes, checksums, processed uploads, or passwords meeting every rule. Skip it for simple fields that are valid by default.

When should success be announced to screen readers?

Once, when an asynchronous check completes successfully, through a polite role="status" region. Do not announce on every keystroke, and do not repeat the same announcement.

What happens to the checkmark if the user edits the field?

Remove it immediately. Success belongs to the value that was checked; a changed value has no verdict until it is checked again.

← Back to Success States and Positive Feedback