ARIA Attributes for Accessible Forms: Adding Labels, Roles, and Error Handling

Coding ProjectsARIA Attributes for Accessible Forms: Adding Labels, Roles, and Error Handling

Think your slick signup form works for everyone? A missing label or a styled div button can make it invisible to people using screen readers or keyboards. ARIA (Accessible Rich Internet Applications) fills those gaps without replacing good HTML. In this post you’ll get a step-by-step guide to add labels, roles, and error handling to forms, plus focus and live updates, with before-and-after code so you can test as you go. By the end you’ll have forms that actually work for everyone.

Why ARIA Matters for Web Forms

xE6vo03qUGqN-DvklT8g1w

Forms break more than most developers realize. A single missing label turns a quick signup into a frustrating guessing game. A submit button styled as a div might look fine but disappears completely to screen reader users. ARIA (Accessible Rich Internet Applications) fills the gaps when native HTML can’t fully describe what’s happening on screen.

ARIA isn’t a replacement for semantic HTML. It’s a repair kit. When you build a custom date picker or a multi-step form wizard, ARIA gives you the vocabulary to tell assistive technology what each piece does, what state it’s in, and how it relates to other elements. The Web Content Accessibility Guidelines rely on ARIA to make dynamic content and custom widgets perceivable, operable, and understandable.

The W3C published the first WAI-ARIA working draft on September 15, 2008. It became a formal recommendation on March 20, 2014. Today, WAI-ARIA 1.2 is the current standard, and WAI-ARIA 1.3 is in draft. Browser and screen reader support has improved steadily since 2019, but inconsistencies still exist. Some attributes work flawlessly across platforms. Others require testing and fallback strategies.

This guide walks you through adding ARIA to forms step by step. You’ll learn when to use ARIA, how to label inputs programmatically, how to expose validation errors, and how to manage focus and live updates. Every step includes before and after code so you can see exactly what changes and why.

The Five Rules of ARIA Use

ecaDbv2eXFmVqj88CBlawA

ARIA is powerful, but it’s easy to misuse. The W3C publishes five explicit rules that every developer should follow. These rules prevent common mistakes and keep your markup predictable for assistive technology.

Rule 1: Use native HTML whenever possible. If a native element does the job, use it. A <button> beats a <span role="button">. A <label> beats aria-label on a wrapper div. Native elements come with built in semantics, keyboard behavior, and focus management. ARIA attributes only describe semantics. They don’t add behavior.

Rule 2: Don’t change native HTML semantics. Don’t override what an element already means. Don’t add role="button" to a link or role="navigation" to a <button>. If you need button behavior, use a button element. Changing roles confuses screen readers and breaks user expectations.

Rule 3: Ensure keyboard accessibility for all interactive ARIA controls. If you add role="checkbox" to a <div>, you must also handle Space to toggle it, Enter where appropriate, and ensure it’s focusable with tabindex="0". ARIA roles tell assistive tech what something is, but you still have to wire up all the interactions yourself.

Rule 4: Don’t set role="presentation" or aria-hidden="true" on focusable elements. Hiding a button from screen readers while leaving it keyboard focusable creates a phantom control. Users can tab to it, but they have no idea what it is. If an element is interactive, it must be perceivable.

Rule 5: Provide an accessible name for every interactive element. Every input, button, link, and custom control needs a name that assistive technology can announce. Use a <label>, aria-label, or aria-labelledby. An unnamed control is unusable.

These five rules act as guardrails. Follow them, and you’ll avoid the majority of ARIA mistakes.

ARIA Components: Roles, States, and Properties

Y5Zws27FV5GP9hnSCHfMYQ

ARIA consists of three main components. Understanding how they work together makes it easier to decide which attributes to use and when.

Roles define what an element is. role="button" tells a screen reader, “This behaves like a button.” role="radiogroup" signals a group of radio buttons. role="alert" marks a message that should interrupt and be announced immediately. Roles are typically set once and don’t change during interaction.

States describe the current condition of an element. aria-checked="true" means a checkbox is checked. aria-expanded="false" means a collapsible section is collapsed. aria-invalid="true" marks a field that failed validation. States change as users interact with the page. You update them with JavaScript to reflect what’s happening.

Properties provide additional information or relationships. aria-label="Close dialog" gives an icon button a text alternative. aria-labelledby="heading-id" points to another element that labels the current one. aria-describedby="error-msg" links an input to its error message. Properties can be static or dynamic depending on the attribute.

ARIA states and properties fall into four categories. Widget attributes describe controls, like aria-checked, aria-pressed, and aria-selected. Relationship attributes connect elements, like aria-labelledby, aria-describedby, and aria-controls. Live region attributes announce dynamic content changes, like aria-live, aria-atomic, and aria-busy. Drag and drop attributes handle draggable interfaces, like aria-grabbed and aria-dropeffect, though these are less common in form contexts.

You don’t need to memorize every attribute. Focus on the ones that solve real problems in forms: labeling inputs, exposing validation states, announcing errors, and grouping related controls.

Labeling Inputs: <label>, aria-label, and aria-labelledby

rSKEpy-0VMKoNVwmmja2Sg

Every input needs a label. Without one, users (especially screen reader users) have no idea what the field is for. There are three main ways to label an input, and each has a specific use case.

The native <label> element is the gold standard. It’s visible, clickable, and works everywhere. The for attribute on the label must match the id on the input.

<!-- Recommended approach -->
<label for="email">Email Address</label>
<input type="email" id="email" />

When a screen reader focuses the input, it announces “Email Address, edit text.” Sighted users can click the label to focus the input. This pattern is simple, robust, and accessible by default.

If you can’t display a visible label (say, for a compact icon only search field), use aria-label directly on the input.

<!-- Use only when a visible label isn't possible -->
<input type="search" id="search" aria-label="Search" />

The screen reader announces “Search, edit text.” The label is programmatically associated but not visible on screen. Use this sparingly. Visible labels benefit everyone, not just screen reader users.

aria-labelledby points to the ID of another element that serves as the label. This is useful when the label text isn’t inside a <label> element or when multiple elements combine to form the label.

<!-- When the label comes from a heading or other element -->
<h3 id="contact-heading">Contact Information</h3>
<input type="text" id="phone" aria-labelledby="contact-heading" />

The screen reader announces “Contact Information, edit text.” You can also reference multiple IDs to concatenate labels.

<span id="label-first">First</span>
<span id="label-name">Name</span>
<input type="text" id="fname" aria-labelledby="label-first label-name" />

This announces “First Name, edit text.” The order of IDs determines the order of the announced text.

Never leave an input without a label. If you see an input with placeholder text but no <label> or ARIA label, fix it. Placeholders aren’t labels. They’re low contrast, they disappear when you type, and assistive technology doesn’t treat them as accessible names.

Required Fields: required vs aria-required

B6NFrk7WXWqVD_A5C5DQgg

Marking a field as required tells users they must fill it out before submitting. There are two ways to do it: the HTML5 required attribute and the ARIA aria-required attribute. The HTML5 version is almost always the better choice.

<!-- Preferred: native HTML -->
<label for="firstname">First Name</label>
<input type="text" id="firstname" required />

The required attribute does three things. It tells the browser to enforce validation before submit. It tells assistive technology the field is mandatory. Most modern browsers automatically set aria-required="true" when required is present. And it triggers the browser’s built in validation message if the user tries to submit an empty field.

If you’re building a custom control that doesn’t support the required attribute, or if you’re working in an environment where HTML5 validation isn’t available, use aria-required as a fallback.

<!-- Fallback for custom controls -->
<label for="custom-field">Custom Field</label>
<div role="textbox" id="custom-field" aria-required="true" contenteditable="true"></div>

The screen reader will announce “Custom Field, edit text, required.” But aria-required only informs assistive technology. It doesn’t prevent submission or trigger validation. You have to enforce the requirement yourself with JavaScript.

When you use required, most browsers will also display a visual indicator (like an asterisk or a different border color) in some contexts. When you use aria-required, you need to add that visual indicator yourself. A common pattern is to append an asterisk to the label and style it clearly.

<label for="lastname">Last Name <span aria-hidden="true">*</span></label>
<input type="text" id="lastname" required />

The aria-hidden="true" on the asterisk prevents screen readers from announcing it, because the required attribute already communicates the same information. Sighted users see the asterisk. Screen reader users hear “required.”

If you’re using a modern browser and native HTML inputs, stick with required. It’s simpler, more robust, and better supported. Reserve aria-required for edge cases where native semantics aren’t available.

Validation Errors: aria-invalid and aria-describedby

oQ0qj8iXWn2ku05oxQU91Q

When a user submits a form with errors, assistive technology needs to know two things: which fields are invalid, and what the error message says. ARIA provides two attributes to solve this: aria-invalid and aria-describedby.

aria-invalid marks a field as invalid. It accepts three values: true, false, or grammar. In form validation, you’ll almost always use true or false. When a field passes validation, set aria-invalid="false" or remove the attribute entirely. When it fails, set aria-invalid="true".

<!-- Before validation runs -->
<label for="email">Email Address</label>
<input type="email" id="email" />

<!-- After validation detects an error -->
<label for="email">Email Address</label>
<input type="email" id="email" aria-invalid="true" aria-describedby="email-error" />
<span id="email-error">Please enter a valid email address.</span>

The screen reader announces “Email Address, edit text, invalid” when the user focuses the field. That tells them something is wrong, but it doesn’t explain what. That’s where aria-describedby comes in.

aria-describedby points to the ID of the element that contains the error message. When the user focuses the input, the screen reader reads the label, the role, the invalid state, and then the description.

The announcement becomes: “Email Address, edit text, invalid. Please enter a valid email address.”

You can use aria-describedby for more than just errors. It’s also useful for help text, formatting instructions, or character limits.

<label for="username">Username</label>
<input type="text" id="username" aria-describedby="username-help" />
<span id="username-help">Must be 6 to 20 characters, letters and numbers only.</span>

When the field is valid, the announcement is: “Username, edit text. Must be 6 to 20 characters, letters and numbers only.”

When an error occurs, you can reference both the help text and the error message by listing both IDs.

<input 
  type="text" 
  id="username" 
  aria-invalid="true" 
  aria-describedby="username-help username-error" 
/>
<span id="username-help">Must be 6 to 20 characters, letters and numbers only.</span>
<span id="username-error">Username is too short.</span>

The screen reader announces: “Username, edit text, invalid. Must be 6 to 20 characters, letters and numbers only. Username is too short.”

The order of IDs in aria-describedby controls the order of announcement. Put the help text first, then the error.

Here’s a simple JavaScript pattern to toggle the invalid state and inject the error message.

const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');

function validateEmail() {
  const value = emailInput.value;
  const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

  if (!isValid) {
    emailInput.setAttribute('aria-invalid', 'true');
    emailInput.setAttribute('aria-describedby', 'email-error');
    emailError.textContent = 'Please enter a valid email address.';
  } else {
    emailInput.setAttribute('aria-invalid', 'false');
    emailInput.removeAttribute('aria-describedby');
    emailError.textContent = '';
  }
}

emailInput.addEventListener('blur', validateEmail);

When the user leaves the field, the function runs. If the value is invalid, it sets aria-invalid="true", links the error message with aria-describedby, and populates the error text. If it’s valid, it resets everything.

This pattern works for any input type. You can adapt it for inline validation on change, on blur, or on submit.

Announcing Errors with Live Regions: aria-live and role="alert"

xT5vlehdXNOD2uzN8H4pSQ

Setting aria-invalid="true" and aria-describedby works when the user navigates to the field. But what if the error appears dynamically after submit, and the user’s focus is still on the submit button? The user might not know the error message appeared unless you announce it.

Live regions solve this. They tell assistive technology to announce content changes without moving focus. The two most common live region attributes are aria-live and role="alert".

aria-live accepts two useful values: polite and assertive. polite waits for a natural pause in speech before announcing. assertive interrupts immediately. Use polite for most status updates and validation messages. Reserve assertive for urgent alerts, like “Your session will expire in one minute.”

<div aria-live="polite" id="form-status"></div>

When you update the contents of this div, the screen reader announces the new text at the next opportunity.

const statusDiv = document.getElementById('form-status');

function announceError(message) {
  statusDiv.textContent = message;
}

// On form submit, if validation fails:
announceError('There are 2 errors in your form. Please review and try again.');

The user hears the message without moving focus. This is especially helpful when you display a summary of errors at the top of the form after submit.

role="alert" is shorthand for aria-live="assertive" plus aria-atomic="true". It interrupts speech and reads the entire contents of the element, even if only part of it changed.

<div role="alert" id="alert-box"></div>

When you inject an error message into this div, the screen reader announces it immediately.

const alertBox = document.getElementById('alert-box');

function showAlert(message) {
  alertBox.textContent = message;
}

// Example: password too short
showAlert('Password must be at least 8 characters.');

Use role="alert" for critical, time sensitive messages. Use aria-live="polite" for everything else.

One common mistake is adding aria-live or role="alert" to an element that already contains text when the page loads. Live regions only announce changes after the page is ready. If the text is already there, nothing happens. You need to inject or update the content with JavaScript after the DOM is interactive.

Another mistake is putting aria-live on the input itself. Live regions should be on the container that holds the dynamic content, not on the control.

<!-- Wrong: aria-live on the input -->
<input type="text" id="username" aria-live="polite" />

<!-- Correct: aria-live on the error container -->
<input type="text" id="username" aria-invalid="true" aria-describedby="username-error" />
<div aria-live="polite" id="username-error">Username is required.</div>

When you update the text inside the live region, the screen reader announces it. If you clear the error, set the textContent to an empty string. The screen reader will stay silent because there’s nothing to announce.

For a full form submission flow, combine aria-invalid, aria-describedby, and a live region summary.

<form id="signup-form">
  <div role="alert" id="form-alert" aria-live="assertive"></div>

  <label for="email">Email</label>
  <input type="email" id="email" />
  <span id="email-error"></span>

  <label for="password">Password</label>
  <input type="password" id="password" />
  <span id="password-error"></span>

  <button type="submit">Sign Up</button>
</form>

When the user submits with invalid fields, your JavaScript updates the live alert, marks each invalid field with aria-invalid="true", links the error messages with aria-describedby, and moves focus to the first invalid field.

const formAlert = document.getElementById('form-alert');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');

function handleSubmit(event) {
  event.preventDefault();
  let hasErrors = false;

  if (!emailInput.value) {
    emailInput.setAttribute('aria-invalid', 'true');
    emailInput.setAttribute('aria-describedby', 'email-error');
    emailError.textContent = 'Email is required.';
    hasErrors = true;
  }

  if (hasErrors) {
    formAlert.textContent = 'There are errors in your form. Please review.';
    emailInput.focus();
  } else {
    formAlert.textContent = 'Form submitted successfully.';
  }
}

document.getElementById('signup-form').addEventListener('submit', handleSubmit);

The alert announces the summary. The focus moves to the first invalid field. The user hears the label, the invalid state, and the error message all in one pass.

Grouping Related Controls: <fieldset>, <legend>, and role="group"

7ZtB550wWB-Den7LRfqQ6w

Some form controls belong together. Radio buttons for selecting a shipping method. Checkboxes for subscribing to email lists. Text fields for entering an address. When controls are related, group them so assistive technology announces the group label along with each control.

The native way to group controls is <fieldset> and <legend>. The <fieldset> wraps the group. The <legend> provides the group label. Screen readers announce the legend when focus enters the group, then announce it again with each control inside.

<fieldset>
  <legend>Shipping Method</legend>
  <label>
    <input type="radio" name="shipping" value="standard" />
    Standard (5 to 7 days)
  </label>
  <label>
    <input type="radio" name="shipping" value="express" />
    Express (2 to 3 days)
  </label>
  <label>
    <input type="radio" name="shipping" value="overnight" />
    Overnight
  </label>
</fieldset>

When the user tabs into the first radio button, the screen reader says: “Shipping Method, Standard (5 to 7 days), radio button, not checked, 1 of 3.”

The legend provides context. Without it, the user hears “Standard (5 to 7 days), radio button” with no indication of what the choice is for.

<fieldset> and <legend> are required for radio groups and strongly recommended for checkbox groups. They’re also useful for grouping related text inputs, like fields for a mailing address.

<fieldset>
  <legend>Billing Address</legend>
  <label for="street">Street</label>
  <input type="text" id="street" />

  <label for="city">City</label>
  <input type="text" id="city" />

  <label for="zip">ZIP Code</label>
  <input type="text" id="zip" />
</fieldset>

Each field is announced with “Billing Address” as part of its context.

If you can’t use <fieldset> and <legend> (for example, if your layout breaks or you’re working with a custom component), you can use role="group" or role="radiogroup" on a container element and aria-labelledby to point to the group label.

<div role="radiogroup" aria-labelledby="shipping-label">
  <span id="shipping-label">Shipping Method</span>
  <label>
    <input type="radio" name="shipping" value="standard" />
    Standard
  </label>
  <label>
    <input type="radio" name="shipping" value="express" />
    Express
  </label>
</div>

The screen reader announces “Shipping Method, radio group” when focus enters, then announces each option.

role="group" works for any collection of related controls. role="radiogroup" is specific to radio buttons and includes extra keyboard behavior expectations (arrow keys should move between options).

For checkbox groups, use <fieldset> when possible. If not, use role="group" with aria-labelledby.

<fieldset>
  <legend>Email Preferences</legend>
  <label>
    <input type="checkbox" name="prefs" value="news" />
    Newsletter
  </label>
  <label>
    <input type="checkbox" name="prefs" value="updates" />
    Product Updates
  </label>
</fieldset>

The screen reader announces “Email Preferences, Newsletter, checkbox, not checked” when the user reaches the first checkbox.

Always provide a group label. Always use <fieldset> and <legend> for native inputs when layout permits. Fall back to role="group" and aria-labelledby only when necessary.

Buttons and Links: Native Elements vs ARIA Roles

asrtZY59VW2G1d-VxL1mhA

Buttons and links are interactive elements, and they behave differently. A button triggers an action. A link navigates to a new location. When you use the native <button> and <a> elements, you get the correct semantics and keyboard behavior for free.

A <button> can be activated with Space or Enter. A link can only be activated with Enter. If you turn a link into a button by adding role="button", you confuse users because the expected keyboard behavior changes.

<!-- Wrong: link pretending to be a button -->
<a href="#" role="button" onclick="submitForm();">Submit</a>

<!-- Correct: use a button -->
<button type="submit" onclick="submitForm();">Submit</button>

The native <button> gives you submit behavior, keyboard support, and focus management. The link with role="button" still activates only on Enter unless you manually add a Space key handler, and it doesn’t communicate submit intent to the browser.

If you’re building a custom button from a <div> or <span>, you need to add role="button", tabindex="0", and keyboard event handlers for both Space and Enter.

<!-- Custom button -->
<span role="button" tabindex="0" onclick="handleClick();">Click Me</span>

<script>
function handleClick() {
  alert('Button clicked');
}

document.querySelector('[role="button"]').addEventListener('keydown', function(e) {
  if (e.key === ' ' || e.key === 'Enter') {
    e.preventDefault();
    handleClick();
  }
});
</script>

This works, but it’s more code, more testing, and more surface area for bugs. Use a <button> instead.

<!-- Simpler and more robust -->
<button type="button" onclick="handleClick();">Click Me</button>

The same principle applies to icon buttons. If you have a button with no visible text (just an icon), you still need an accessible name. Use aria-label or a visually hidden text label.

<!-- Icon button with aria-label -->
<button type="button" aria-label="Close dialog">
  <svg aria-hidden="true"><!-- icon --></svg>
</button>

The aria-hidden="true" on the SVG prevents the screen reader from announcing decorative graphic information. The aria-label on the button provides the accessible name.

Alternatively, use a visually hidden <span> inside the button.

<button type="button">
  <svg aria-hidden="true"><!-- icon --></svg>
  <span class="visually-hidden">Close dialog</span>
</button>

The CSS for .visually-hidden clips the text to a 1 pixel box so it’s invisible on screen but still announced by screen readers.

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Never use visibility: hidden or display: none for accessible text. Those properties hide content from assistive technology as well.

For form buttons, always specify the type attribute. type="submit" submits the form. type="button" does not. type="reset" clears the form. Omitting the type defaults to submit, which can cause unexpected behavior if you intended the button to trigger JavaScript without submitting.

<button type="submit">Submit</button>
<button type="button" onclick="cancel();">Cancel</button>
<button type="reset">Clear Form</button>

If you’re using <input> elements as buttons, use the value attribute for the label.

<input type="submit" value="Submit" />
<input type="button" value="Cancel" onclick="cancel();" />

For image buttons, use the alt attribute.

<input type="image" src="submit-icon.png" alt="Submit form" />

The screen reader announces “Submit form, button.”

Stick with native elements. Use ARIA roles only when you’re building custom widgets that have no native equivalent. For buttons and links, native HTML is always the better choice.

Managing Focus and Keyboard Navigation with tabindex

UbKOk7QsXlmt1_MdEW5hvQ

Keyboard users navigate forms by pressing Tab to move forward and Shift+Tab to move backward. By default, only interactive elements are focusable: links, buttons, inputs, selects, and textareas. If you need to make a non-interactive element focusable, use tabindex.

tabindex="0" adds an element to the natural tab order. The element becomes focusable in the order it appears in the DOM.

<div role="button" tabindex="0" onclick="handleClick();">Custom Button</div>

The div is now reachable with the Tab key and participates in the normal focus order.

tabindex="-1" makes an element programmatically focusable but removes it from the tab order. You can call .focus() on it with JavaScript, but the user can’t reach it by tabbing.

<div id="error-summary" tabindex="-1">Please correct the errors below.</div>

<script>
document.getElementById('error-summary').focus();
</script>

This is useful for moving focus to a status message or error summary after form submission.

Never use a positive tabindex value like tabindex="1" or tabindex="5". Positive values override the natural DOM order and create a confusing, unpredictable tab sequence. Screen reader users expect tab order to follow visual layout. Breaking that expectation makes forms harder to use.

<!-- Never do this -->
<input type="text" tabindex="3" />
<button type="submit" tabindex="1" />
<input type="email" tabindex="2" />

The tab order becomes: button, email, text. The visual order is text, email, button. This mismatch frustrates everyone.

If you need to control focus order, restructure your DOM so the source order matches the visual order. Then you won’t need tabindex at all.

When focus moves to a custom control, you also need to handle keyboard interactions. Buttons should activate on Space and Enter. Radio buttons should allow arrow keys to move between options. Checkboxes toggle on Space. The WAI-ARIA Authoring Practices Guide documents the expected keyboard behavior for every widget type.

For example, a custom radio group built with role="radiogroup" should support these keys:

Tab moves focus into and out of the group. Arrow keys move focus between radio buttons within the group. Space selects the focused radio button.

If you don’t implement these patterns, the widget feels broken to keyboard users.

Focus management also matters after form submission. If the form submits successfully, you might want to move focus to a success message or the next step in a wizard. If validation fails, move focus to the first invalid field or to an error summary at the top of the form.

function handleSubmit(event) {
  event.preventDefault();
  const firstError = document.querySelector('[aria-invalid="true"]');

  if (firstError) {
    firstError.focus();
  } else {
    document.getElementById('success-message').focus();
  }
}

This ensures the user lands in the right place and hears the relevant feedback immediately.

For modals and dialogs, you need a focus trap. When a modal opens, focus moves to the first focusable element inside the modal. Tab and Shift+Tab cycle through the modal’s interactive elements without escaping back to the page behind it. When the modal closes, focus returns to the trigger element.

Implementing a focus trap manually is complex. You need to find all focusable elements inside the modal, listen for Tab and Shift+Tab, wrap focus back to the top or bottom when the user reaches the end, and store the original focus target and restore it when the modal closes.

Many frameworks and libraries handle this automatically. If you’re building from scratch, use a library like focus-trap or refer to the ARIA Authoring Practices dialog examples.

Always test keyboard navigation. Tab through the form without using the mouse. Make sure every interactive element is reachable, the order makes sense, and focus indicators are visible.

Custom Controls: Checkboxes and Radio Buttons with ARIA

Sometimes you need to style a checkbox or radio button in a way that native inputs can’t support. When that happens, you can build a custom control using ARIA. This is one of the few cases where ARIA roles, states, and properties aren’t just describing native elements. They’re providing all the semantics themselves.

A custom checkbox requires role="checkbox", tabindex="0", and aria-checked to track state. You also need to handle Space to toggle the checkbox and update aria-checked when the state changes.

<div 
  role="checkbox" 
  aria-checked="false" 
  aria-labelledby="subscribe-label" 
  tabindex="0" 
  id="subscribe-checkbox"
></div>
<span id="subscribe-label">Subscribe to newsletter</span>

<script>
const checkbox = document.getElementById('subscribe-checkbox');

checkbox.addEventListener('click', toggleCheckbox);
checkbox.addEventListener('keydown', function(e) {
  if (e.key === ' ') {
    e.preventDefault();
    toggleCheckbox();
  }
});

function toggleCheckbox() {
  const checked = checkbox.getAttribute('aria-checked') === 'true';
  checkbox.setAttribute('aria-checked', !checked);
}
</script>

When the user focuses the checkbox, the screen reader announces “Subscribe to newsletter, checkbox, not checked.” When they press Space, aria-checked toggles to true, and the announcement becomes “Subscribe to newsletter, checkbox, checked.”

You also need to style the visual appearance to match the state. Use a CSS attribute selector.

[role="checkbox"][aria-checked="true"]::before {
  content: '✓';
}

For a custom radio button, use role="radio" inside a role="radiogroup". Each radio needs tabindex, aria-checked, and arrow key navigation. Only one radio in the group should be checked at a time.

“`html

Favorite Color

Check out other tags: