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.
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 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.
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.
Related Guides
- Success States and Positive Feedback — the wider design of positive feedback.
- Form Submission Success Confirmation Patterns — success for whole submissions.
- Username Validation Rules and Reserved Names — a verified field that suits a checkmark.
- Debouncing Real-Time Validation Input — the timing behind verification checks.