diff --git a/tutorials/html/13-accessibility/accessible-buttons.mdx b/tutorials/html/13-accessibility/accessible-buttons.mdx
index e69de29bb..a3f067158 100644
--- a/tutorials/html/13-accessibility/accessible-buttons.mdx
+++ b/tutorials/html/13-accessibility/accessible-buttons.mdx
@@ -0,0 +1,594 @@
+---
+id: html-accessible-buttons
+title: "Accessible Buttons"
+sidebar_label: "Accessible Buttons"
+sidebar_position: 11
+slug: /html/accessibility/accessible-buttons
+description: "Learn how to build buttons that work for everyone: use the real button element, set the type, name icon buttons, make toggle and disclosure buttons with aria-pressed and aria-expanded, handle disabled and loading states, and avoid fake buttons made from div and span. Includes a toggle lab, a disabled-button lab, and a live button checker."
+keywords:
+ - accessible buttons
+ - button element
+ - aria-pressed
+ - aria-expanded
+ - aria-disabled
+ - disabled button
+ - toggle button
+ - icon button
+ - div as button
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, buttons, aria, keyboard, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export function ToggleLab() {
+ const [muted, setMuted] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [changeLabel, setChangeLabel] = useState(false);
+ const label = changeLabel ? (muted ? "Unmute" : "Mute") : "Mute";
+ const toggleSpoken = label + ", toggle button, " + (muted ? "pressed" : "not pressed");
+ const discSpoken = "Delivery details, button, " + (open ? "expanded" : "collapsed");
+ return (
+
+
+ setChangeLabel(event.target.checked)} /> Also change the label when it is pressed (a common mistake)
+
+
+
A toggle button (on or off)
+
setMuted(!muted)} style={{padding: "0.4rem 0.9rem", fontWeight: muted ? 700 : 400, background: muted ? "#dbeafe" : undefined, color: muted ? "#1e293b" : undefined, border: muted ? "2px solid #1d4ed8" : undefined}}>
+ {muted ? "✓ " : ""} {label}
+
+
A screen reader may say: {toggleSpoken}
+ {changeLabel ?
Problem: when the label changes and the state is announced, people hear "Unmute, pressed", and cannot tell whether sound is on or off.
:
The label stays the same, and the state carries the change. That is the correct pattern.
}
+
+
+
A disclosure button (show or hide a panel)
+
setOpen(!open)}>
+ {open ? "▾ " : "▸ "} Delivery details
+
+
+ Free delivery on orders over 20 dollars. We deliver Tuesday to Saturday.
+
+
A screen reader may say: {discSpoken}
+
+
+ Press each button with Enter or Space. The exact words differ between screen readers (some say "toggle button", some say "pressed", some say "checked"), so the lines above are examples, not exact output. The rule stays the same: a stable label, and a changing state.
+
+
+ );
+}
+
+export function DisabledLab() {
+ const [mode, setMode] = useState("native");
+ const [agree, setAgree] = useState(false);
+ const [message, setMessage] = useState("");
+ const [where, setWhere] = useState("nowhere yet");
+ const onFocus = (event) => setWhere(event.target.getAttribute("data-name") || event.target.tagName.toLowerCase());
+ const place = () => {
+ if (mode === "aria" && !agree) {
+ setMessage("Please accept the terms before placing your order.");
+ return;
+ }
+ setMessage("Order placed. (This is a demo, so nothing was sent.)");
+ };
+ return (
+
+
+ How should the unavailable button work?
+ { setMode("native"); setMessage(""); }} /> Native disabled
+ { setMode("aria"); setMessage(""); }} /> aria-disabled
+
+
+ { setAgree(event.target.checked); setMessage(""); }} /> I accept the terms
+
+
{agree ? "Ready when you are." : "Accept the terms to place your order."}
+
+ Place order {" "}
+ Cancel
+
+
{message}
+
Focus last landed on: {where}
+
+ Leave the box unticked, click the checkbox, then press Tab. With native disabled, focus skips straight past the Place order button, so a keyboard user may never learn it exists or why it does not work. With aria-disabled, focus lands on it, the reason is read out (it is connected with aria-describedby), and pressing it explains the problem. The trade-off: you must block the action yourself, and make it look unavailable. Both ways can work, as long as the reason is visible near the button.
+
+
+ );
+}
+
+export const buttonSamples = {
+ messy: '
+
+ Which page do you want to check?
+ choose("messy")} /> A messy page
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+
+
{rows.length} buttons and button-like items found, {bad} with problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + ">"} {row.name ? {'"' + row.name + '"'} : no name }{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+
+ This reads the HTML only, and runs no scripts. It finds common problems, but cannot see styles, scripts added later, or whether a name is clear. Always test with a keyboard as well.
+
+
+ );
+}
+
+export function ButtonChecker() {
+ return `, use it.
+:::
+
+## The `type` Attribute
+
+A `button` inside a `form` is a **submit button by default.** That is a classic source of bugs: a "Show password" button that unexpectedly submits the form.
+
+| Type | What it does |
+| :-- | :-- |
+| `type="submit"` | Submits the form (the default inside a form). |
+| `type="button"` | Does nothing by itself. Use it for actions run by script. |
+| `type="reset"` | Resets the form fields. Rarely a good idea. |
+
+**Always write the `type`.** Use `type="submit"` for the one real submit button, and `type="button"` for everything else.
+
+There is also `input type="submit"`, `input type="button"`, and `input type="image"`. They work, but `button` is more flexible because it can hold text, icons, and other content.
+
+## Naming Buttons
+
+A button's name comes from its **content,** so a visible text label is the best choice. See [Accessible Names](./accessible-names) for the full rules.
+
+- **Use a verb or action:** "Save recipe," "Add to basket," "Send message."
+- **Be specific.** If there are several "Delete" buttons on a page, make each one unique: "Delete sourdough recipe."
+- **Do not repeat the role:** "Save," not "Save button."
+- **Icon-only buttons** need a name with `aria-label` (or hidden text), and the icon should be hidden with `aria-hidden="true"`.
+- **Match the visible label** in the name, so voice control works (WCAG 2.5.3).
+- **Keep the name the same** when the state changes (see below).
+
+```html
+
+
+
+
+
+```
+
+## Toggle Buttons: `aria-pressed`
+
+A **toggle button** switches something on or off, such as "Mute," "Bold," or "Favourite." Use `aria-pressed`:
+
+```html
+Mute
+```
+
+Your script flips the value to `"true"` and back. The **label stays the same,** and the **state** carries the change. If you change the label too ("Mute" to "Unmute") **and** use `aria-pressed`, people hear "Unmute, pressed," which is confusing, as the lab showed. Pick **one** approach:
+
+- **Stable label + `aria-pressed`** (best for toggles), or
+- **Changing label with no `aria-pressed`** (fine for simple actions like "Play" and "Pause").
+
+Do not rely on **colour alone** to show that a toggle is on. Use a border, an icon, or bold text as well, as the lab does.
+
+## Disclosure Buttons: `aria-expanded`
+
+A button that **shows or hides** something (a menu, a panel, an accordion section) uses `aria-expanded`:
+
+```html
+
+ Delivery details
+
+…
+```
+
+When opened, set `aria-expanded="true"` and remove `hidden` from the panel. `aria-controls` points to the panel (support from screen readers varies, so do not rely on it alone). For simple show-and-hide content, the native `details` and `summary` elements do the same job with **no** script at all.
+
+## Disabled Buttons
+
+The `disabled` attribute makes a button **unavailable.** It cannot be clicked, and it is **removed from the Tab order.** That is sometimes right, but it has a cost: **keyboard and screen reader users may never find it,** and may not know why it does not work.
+
+Two common choices:
+
+- **Native `disabled`:** simple, and fully blocks the button. Use it when the reason is obvious, and say **why** in nearby text.
+- **`aria-disabled="true"`:** the button stays **focusable,** and is announced as "dimmed" or "unavailable." You must **block the action yourself** in script, and **style** it so it looks unavailable. This lets you explain the reason when someone presses it.
+
+
+
+
+```html title="index.html"
+
+I accept the terms
+
+Accept the terms to place your order.
+
+
+Place order
+
+
+
+ Place order
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Another option is to leave the button **enabled** and show an **error message** when someone presses it too early, which is often the friendliest design of all. Whatever you choose, **do not rely on a greyed-out look alone.** Disabled text is also exempt from the usual contrast rules, so make sure that the reason is stated in readable text.
+
+## Loading States
+
+When a button starts something that takes time (saving, paying), people need to know it is **working,** and must not press it twice.
+
+- **Change the text** ("Saving...") or show a spinner **with a text alternative.**
+- **Announce it** with a live region (see [ARIA Live Regions](./aria-live)), so screen reader users hear "Saving" and then "Saved."
+- **Ignore extra clicks** while busy, rather than removing the button.
+- **Be careful with `disabled`.** If you disable the button the person just pressed, it **loses focus,** and keyboard users are thrown back to the top of the page. Use `aria-disabled` and ignore clicks, or move focus on purpose. See [Focus Management](./focus-management).
+
+## Make Buttons Easy to See and Hit
+
+- **Show a clear focus style,** and never remove the outline without a better replacement.
+- **Contrast:** the text needs at least **4.5:1** against the button background, and the button's edge or background needs **3:1** against what is around it (WCAG 1.4.11), so it is recognisable as a button.
+- **Size:** WCAG 2.2 sets a minimum target size of **24 by 24 CSS pixels** (with some exceptions). Around **44 by 44** is a much more comfortable size for touch screens, especially for people with tremors.
+- **Space:** leave a gap between buttons, so a slip does not hit the wrong one.
+- **Look like a button.** A border, background, or clear shape helps everyone, and especially people with cognitive differences.
+- **Do not use a fixed width.** Translated or enlarged text must fit.
+
+```css
+button {
+ min-height: 44px;
+ padding: 0.5rem 1rem;
+ border: 2px solid #1d4ed8;
+ border-radius: 6px;
+ background: #1d4ed8;
+ color: #ffffff;
+}
+
+button:focus-visible {
+ outline: 3px solid #0f172a;
+ outline-offset: 3px;
+}
+```
+
+## Button or Link?
+
+If it **goes somewhere,** use a link. If it **does something,** use a button. Do not style one to look like the other without a good reason, because people expect different things, including the key that activates it (Enter only for links, Enter and Space for buttons). The full comparison is in [Accessible Links](./accessible-links).
+
+## Check Your Buttons
+
+The checker below looks for the problems we have covered: fake buttons made from `div` and `span`, links used as buttons, a missing `type` in a form, missing names, positive `tabindex`, interactive elements nested in buttons, and disabled buttons that deserve an explanation.
+
+
+<>
+
+>
+
+
+On a real site, do these quick tests:
+
+1. **Press Tab.** Can you reach every button, and see where you are?
+2. **Press Enter and Space** on each button. Do both work?
+3. **Look in the Accessibility panel.** Does each button have a role and a name?
+4. **Listen with a screen reader.** Do toggle and disclosure buttons announce their state?
+5. **Use voice control.** Say "click" and the visible label.
+6. **Zoom in to 200 percent,** and check that the buttons still fit and work.
+
+## Good Habits
+
+1. **Use `button`,** and write its `type` every time.
+2. **Name every button,** with a clear verb, and make the visible text part of the name.
+3. **Use `aria-pressed`** for on and off, and **`aria-expanded`** for show and hide, with a stable label.
+4. **Explain why** a button is unavailable, in text near it.
+5. **Do not disable** a button the person is using. Move focus on purpose if you must.
+6. **Give feedback** when something starts, finishes, or fails.
+7. **Make buttons big enough,** with clear focus and good contrast.
+8. **Ask for confirmation, or offer undo,** for actions that cannot be reversed.
+9. **Test with a keyboard,** a screen reader, and a phone.
+
+## Accessibility Checklist
+
+- [ ] Every button is a real `button` (or `input type="button"` or `submit`).
+- [ ] Every button has a `type`.
+- [ ] Every button has a clear accessible name, and icon-only buttons are labelled.
+- [ ] Toggle buttons use `aria-pressed`, and disclosure buttons use `aria-expanded`, with stable labels.
+- [ ] Buttons can be reached with Tab and used with both Enter and Space.
+- [ ] Focus is clearly visible.
+- [ ] Text and button edges have enough contrast.
+- [ ] Targets are large enough, and spaced apart.
+- [ ] Disabled and loading states are explained, and announced.
+- [ ] Destructive actions have a confirmation, or an undo.
+
+## Security and Privacy Notes
+
+- **Prevent double submits.** A second click on "Pay" or "Send" can charge or send twice. Ignore repeat clicks while a request is running, and make the server safe against repeats as well.
+- **Confirm or allow undo for destructive actions** (delete, pay, share). Make the **safe** choice, such as Cancel, the default focus in confirmation dialogs.
+- **Be honest in labels.** A button called "Cancel" that really confirms, or a "Close" that accepts all cookies, is deceptive.
+- **Give equal weight to privacy choices.** In a consent banner, "Reject" should be as easy to find, reach, and press as "Accept." Hiding it is a dark pattern, and often against privacy law.
+- **Do not hide submit buttons** or make them invisible. An overlay that tricks people into clicking something else is clickjacking.
+- **Do not collect how people press buttons** (for example, with a keyboard or a switch) in a way that reveals a disability.
+- **Never put secrets in a button's attributes,** such as `data-` values or inline handlers. Anyone can read them.
+
+## Global Audience Notes
+
+- **Translate button text** and `aria-label` values with the rest of the page, and keep buttons wide enough for longer words in other languages. German and Finnish, for example, can be much longer than English.
+- **Avoid icon-only buttons** when you can. Icons are not universal (a hamburger, a heart, or a hand may mean different things in different places). Add a text label.
+- **Right-to-left languages:** arrows and "next" or "back" icons should flip to match the reading direction, and button order in a row should follow it too.
+- **Voice control** users say the visible label in their own language, so the visible text must be in the name.
+- **Slow networks and old devices:** show a loading state straight away, so people do not press twice and wonder if anything happened.
+- **Touch first:** many people use only a phone. Large, well-spaced buttons matter more than on a desktop.
+- **Verbs travel better than clever wording.** "Save," "Send," and "Next" translate well. Puns do not.
+
+## Common Mistakes to Avoid
+
+1. **A `div` or `span` used as a button.**
+2. **A link (`a href="#"`) used as a button.**
+3. **No `type`,** so a button submits the form by accident.
+4. **Icon-only buttons with no name.**
+5. **Changing the label and using `aria-pressed` together.**
+6. **Disabling a button with no explanation.**
+7. **Disabling the button someone just pressed,** which loses focus.
+8. **Removing the focus outline.**
+9. **Tiny buttons close together.**
+10. **Several buttons that all say "Delete" or "Edit."**
+11. **A link or another button nested inside a button.**
+12. **No feedback after pressing.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the toggle lab with and without the label change, and write down what you hear.
+2. Build a "Favourite" button with `aria-pressed`, and a "Show more" button with `aria-expanded`.
+3. Use the disabled lab in both modes. Press Tab from the checkbox, and describe where focus lands.
+4. Replace a `div` button with a real `button`, and compare the keyboard behaviour.
+5. Run the checker on the messy page, and fix every problem.
+6. Add `type="button"` to the buttons in a form you made. Does anything change?
+7. Label an icon-only button, and confirm its name in the Accessibility panel.
+8. Make a "Save" button that shows "Saving..." and announces "Saved" with a live region.
+9. Resize your buttons to 44 pixels tall, and test them on your phone.
+10. Bonus: build a confirmation dialog for "Delete recipe," with focus on the Cancel button.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. Why use a real `button` and not a `div`?
+
+A `button` gives you focus, Enter and Space activation, the button role, a name from its content, `disabled`, and form behaviour for free. A `div` has none of these.
+
+
+
+
+2. What is the risk of a button with no `type` inside a form?
+
+It is a submit button by default, so it can submit the form by accident. Write `type="button"` or `type="submit"` every time.
+
+
+
+
+3. How do you make a toggle button accessible?
+
+Use `aria-pressed`, flip it between `"true"` and `"false"`, and keep the label the same.
+
+
+
+
+4. Which attribute shows that a button opens and closes a panel?
+
+`aria-expanded`, set to `"true"` when open and `"false"` when closed, usually with `aria-controls` pointing to the panel.
+
+
+
+
+5. What is the difference between `disabled` and `aria-disabled`?
+
+`disabled` blocks the button and removes it from the Tab order. `aria-disabled` keeps it focusable and announced as unavailable, but you must block the action yourself.
+
+
+
+
+6. Why can disabling a button after it is pressed be a problem?
+
+A disabled button cannot keep focus, so keyboard users lose their place. Use `aria-disabled` and ignore clicks, or move focus on purpose.
+
+
+
+## Key Takeaways
+
+- **Use the real `button` element.** It gives you focus, keyboard support, role, and name for free.
+- **Always set the `type`.** Inside a form, the default is "submit."
+- **Name every button,** with a clear verb, and keep the visible text in the name.
+- **Toggle:** `aria-pressed`. **Disclosure:** `aria-expanded`. Keep the label stable.
+- **Explain** disabled and loading states, and announce changes.
+- **Make buttons visible, roomy, and high-contrast,** with a clear focus style.
+- **Protect people** from double submits and from destructive mistakes.
+
+Next, learn how to build forms that everyone can complete in [Accessible Forms](./accessible-forms).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/accessible-forms.mdx b/tutorials/html/13-accessibility/accessible-forms.mdx
index e69de29bb..5f24f4aec 100644
--- a/tutorials/html/13-accessibility/accessible-forms.mdx
+++ b/tutorials/html/13-accessibility/accessible-forms.mdx
@@ -0,0 +1,681 @@
+---
+id: html-a11y-accessible-forms
+title: "Accessible Forms"
+sidebar_label: "Accessible Forms"
+sidebar_position: 12
+slug: /html/accessibility/accessible-forms
+description: "Learn how to build forms that everyone can complete: visible labels, fieldset and legend, hints and required fields with aria-describedby, the right input types and autocomplete, clear error messages, sensible validation timing, and safe, accessible sign-in. Includes a live field builder, a validation timing lab, and a form checker."
+keywords:
+ - accessible forms
+ - form labels
+ - fieldset legend
+ - aria-describedby
+ - autocomplete
+ - form validation
+ - error messages
+ - WCAG 3.3
+ - input purpose
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, forms, validation, a11y, frontend]
+---
+
+import {useState, useEffect, useRef} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const fieldTypes = {
+ email: {label: "Email address", type: "email", ac: "email", name: "email", hint: "We only use it to send your receipt.", error: "Enter an email address that includes an @ sign.", role: "edit text"},
+ phone: {label: "Phone number", type: "tel", ac: "tel", name: "phone", hint: "Include your country code, for example +1 555 010 1234.", error: "Enter a phone number with the country code.", role: "edit text"},
+ name: {label: "Full name", type: "text", ac: "name", name: "name", hint: "Use the name you want on the order.", error: "Enter your name.", role: "edit text"},
+ password: {label: "Password", type: "password", ac: "new-password", name: "password", hint: "At least 12 characters. You can paste from a password manager.", error: "Use at least 12 characters.", role: "protected edit text"},
+};
+
+export function FieldBuilder() {
+ const [kind, setKind] = useState("email");
+ const [naming, setNaming] = useState("label");
+ const [showHint, setShowHint] = useState(true);
+ const [required, setRequired] = useState(true);
+ const [showError, setShowError] = useState(false);
+ const [useAc, setUseAc] = useState(true);
+ const spec = fieldTypes[kind];
+ const id = "fb-" + spec.name;
+ const describedby = [showHint ? id + "-hint" : "", showError ? id + "-error" : ""].filter(Boolean).join(" ");
+ const labelText = spec.label + (required ? " (required)" : "");
+ const attrs = ['id="' + id + '"', 'name="' + spec.name + '"', 'type="' + spec.type + '"'];
+ if (useAc) attrs.push('autocomplete="' + spec.ac + '"');
+ if (naming === "arialabel") attrs.push('aria-label="' + spec.label + '"');
+ if (naming === "placeholder") attrs.push('placeholder="' + spec.label + '"');
+ if (required) attrs.push("required");
+ if (describedby) attrs.push('aria-describedby="' + describedby + '"');
+ if (showError) attrs.push('aria-invalid="true"');
+ const lines = [];
+ if (naming === "label") lines.push('' + labelText + " ");
+ if (showHint) lines.push('' + spec.hint + "
");
+ lines.push(" ");
+ if (showError) lines.push('Error: ' + spec.error + "
");
+ const code = lines.join("\n");
+ const name = naming === "none" ? "unlabelled" : spec.label;
+ const spoken = name + ", " + spec.role + (required ? ", required" : "") + (showError ? ", invalid entry" : "") + (showHint ? ". " + spec.hint : "") + (showError ? " Error: " + spec.error : "");
+ const notes = [];
+ if (naming === "none") notes.push("No name at all. A screen reader user hears an unlabelled field.");
+ if (naming === "placeholder") notes.push("A placeholder is not a label. It disappears as you type, and is often low contrast.");
+ if (naming === "arialabel") notes.push("A hidden name works for a screen reader, but sighted users see no label. Use a visible label whenever you can.");
+ if (!useAc) notes.push("Without autocomplete, people have to type this by hand every time, and password managers may not help.");
+ if (required && naming !== "label") notes.push("The word (required) is shown only with a visible label here.");
+ if (notes.length === 0) notes.push("This field has a visible label, a hint, a clear required marker, and the right type.");
+ return (
+
+
+
+ Field
+ setKind(event.target.value)}>
+ Email
+ Phone
+ Full name
+ Password
+
+
+
+ Where does the name come from?
+ setNaming(event.target.value)}>
+ A visible label
+ aria-label only
+ A placeholder only
+ Nothing
+
+
+
+
+ Options
+ setShowHint(event.target.checked)} /> Add a hint
+ setRequired(event.target.checked)} /> Required
+ setUseAc(event.target.checked)} /> Add autocomplete
+ setShowError(event.target.checked)} /> Show an error
+
+
+ {naming === "label" ?
{labelText} : null}
+ {showHint ?
{spec.hint}
: null}
+
+ {showError ?
Error: {spec.error}
: null}
+
+
HTML:
+
{code}
+
A screen reader may say: {spoken}
+
+ {notes.map((note, index) => ({note} ))}
+
+
+ The spoken line is an example. The order and the words differ between screen readers and browsers, but the information (name, type, required, invalid, hint, error) is the same.
+
+
+ );
+}
+
+export function checkEmail(value) {
+ if (value === "") return "Enter an email address.";
+ if (value.indexOf("@") === -1) return "Include an @ sign in the email address.";
+ const after = value.slice(value.indexOf("@") + 1);
+ if (after === "") return "Add the part after the @ sign, such as example.com.";
+ if (after.indexOf(".") === -1) return "The part after the @ sign needs a dot, such as example.com.";
+ return "";
+}
+
+export function TimingLab() {
+ const [mode, setMode] = useState("typing");
+ const [value, setValue] = useState("");
+ const [dirty, setDirty] = useState(false);
+ const [touched, setTouched] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [count, setCount] = useState(0);
+ const [history, setHistory] = useState([]);
+ const lastShown = useRef("");
+ const reset = () => {
+ setValue("");
+ setDirty(false);
+ setTouched(false);
+ setSubmitted(false);
+ setCount(0);
+ setHistory([]);
+ lastShown.current = "";
+ };
+ const shown = mode === "typing" ? (dirty ? checkEmail(value) : "") : mode === "blur" ? (touched ? checkEmail(value) : "") : (submitted ? checkEmail(value) : "");
+ useEffect(() => {
+ if (shown === lastShown.current) return;
+ lastShown.current = shown;
+ if (shown !== "") setCount((previous) => previous + 1);
+ setHistory((previous) => previous.concat(shown === "" ? "(no error)" : shown).slice(-6));
+ }, [shown]);
+ return (
+
+
+ When should the error appear?
+ { setMode("typing"); reset(); }} /> On every key press
+ { setMode("blur"); reset(); }} /> When I leave the field
+ { setMode("submit"); reset(); }} /> When I press Submit
+
+
+
Error messages announced so far: {count}
+
Recent messages: {history.length === 0 ? "(none yet)" : history.join(" -> ")}
+
+ Type a@b.co slowly in each mode. In the first mode, a screen reader user hears a new error after nearly every key, before they have had a chance to finish. Waiting until the person leaves the field, or presses Submit, is usually kinder. A good pattern is to show an error after someone leaves a field, and to remove it as soon as they fix it. Counting messages here is a simulation of what a polite live region would announce, and real screen readers may combine or skip some of them.
+
+
+ );
+}
+
+export const formSamples = {
+ messy: '',
+ fixed: '\n Email address (required) \n \n Phone number (optional) \n \n Full name (required) \n \n Password \n \n \n Delivery \n Standard \n Express \n \n Notes \n \n Place order \n ',
+};
+
+export function fieldNameOf(el, doc) {
+ const clean = (value) => (value || "").replace(/\s+/g, " ").trim();
+ const ids = clean(el.getAttribute("aria-labelledby"));
+ if (ids) {
+ const text = clean(ids.split(" ").map((id) => { const target = doc.getElementById(id); return target ? target.textContent : ""; }).join(" "));
+ if (text) return {name: text, source: "aria-labelledby", labelText: text};
+ }
+ const aria = clean(el.getAttribute("aria-label"));
+ if (aria) return {name: aria, source: "aria-label", labelText: ""};
+ const id = el.getAttribute("id");
+ if (id) {
+ const found = Array.from(doc.querySelectorAll("label")).find((item) => item.getAttribute("for") === id);
+ if (found) return {name: clean(found.textContent), source: "label", labelText: clean(found.textContent)};
+ }
+ const wrap = el.closest("label");
+ if (wrap) {
+ const copy = wrap.cloneNode(true);
+ copy.querySelectorAll("input, select, textarea").forEach((control) => control.remove());
+ return {name: clean(copy.textContent), source: "label", labelText: clean(copy.textContent)};
+ }
+ if (clean(el.getAttribute("title"))) return {name: clean(el.getAttribute("title")), source: "title", labelText: ""};
+ if (clean(el.getAttribute("placeholder"))) return {name: clean(el.getAttribute("placeholder")), source: "placeholder", labelText: ""};
+ return {name: "", source: "none", labelText: ""};
+}
+
+export function FormCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(formSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const controls = Array.from(doc.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]), select, textarea"));
+ const general = [];
+ const ids = {};
+ Array.from(doc.querySelectorAll("[id]")).forEach((el) => { ids[el.id] = (ids[el.id] || 0) + 1; });
+ Object.keys(ids).forEach((id) => { if (ids[id] > 1) general.push("The id \"" + id + "\" is used " + ids[id] + " times. Ids must be unique, or labels connect to the wrong field."); });
+ Array.from(doc.querySelectorAll("label[for]")).forEach((label) => { if (!doc.getElementById(label.getAttribute("for"))) general.push("A label points to the id \"" + label.getAttribute("for") + "\", which does not exist."); });
+ if (doc.querySelector("form") && !doc.querySelector('button:not([type="button"]):not([type="reset"]), input[type="submit"], input[type="image"]')) general.push("The form has no submit button.");
+ const groups = {};
+ controls.forEach((el) => {
+ const type = (el.getAttribute("type") || "").toLowerCase();
+ if ((type === "radio" || type === "checkbox") && el.getAttribute("name")) {
+ const key = type + ":" + el.getAttribute("name");
+ groups[key] = groups[key] || [];
+ groups[key].push(el);
+ }
+ });
+ Object.keys(groups).forEach((key) => {
+ const list = groups[key];
+ if (list.length > 1 && !list.some((el) => el.closest("fieldset"))) general.push("The " + key.split(":")[0] + " group \"" + key.split(":")[1] + "\" is not inside a fieldset with a legend.");
+ });
+ const rows = controls.map((el) => {
+ const tag = el.tagName.toLowerCase();
+ const type = (el.getAttribute("type") || (tag === "input" ? "text" : tag)).toLowerCase();
+ const info = fieldNameOf(el, doc);
+ const hint = ((el.getAttribute("name") || "") + " " + (el.getAttribute("id") || "") + " " + info.name).toLowerCase();
+ const ac = (el.getAttribute("autocomplete") || "").toLowerCase();
+ const issues = [];
+ const notes = [];
+ if (info.source === "none") issues.push("No label or name.");
+ if (info.source === "placeholder") issues.push("Only a placeholder, which is not a real label.");
+ if (info.source === "title") issues.push("Only a title attribute. Use a visible label.");
+ if (info.source === "aria-label") notes.push("Named with aria-label only. A visible label is better for everyone.");
+ if (type === "text" && /e-?mail/.test(hint)) issues.push("This looks like an email field. Use type=\"email\".");
+ if (type === "text" && /(phone|tel|mobile)/.test(hint)) issues.push("This looks like a phone field. Use type=\"tel\".");
+ const expected = type === "email" || /e-?mail/.test(hint) ? "email" : type === "tel" || /(phone|tel\b|mobile)/.test(hint) ? "tel" : type === "password" ? "password" : /(^|[^a-z])(full )?name/.test(hint) ? "name" : "";
+ if (expected && ac === "") issues.push("Add an autocomplete value (for example " + (expected === "password" ? "new-password or current-password" : expected) + ").");
+ if (expected && ac === "off") issues.push("autocomplete=\"off\" blocks autofill and password managers, and fails WCAG 1.3.5 for personal data.");
+ if (el.hasAttribute("required") && !/(required|mandatory|\*)/i.test(info.labelText || "")) notes.push("Say that the field is required in visible words, such as (required), not only with the attribute.");
+ if (el.getAttribute("aria-invalid") === "true" && !el.getAttribute("aria-describedby")) issues.push("Marked invalid, but no error message is connected with aria-describedby.");
+ if (/return\s+false/i.test(el.getAttribute("onpaste") || "")) issues.push("Blocks pasting. This breaks password managers and fails WCAG 3.3.8.");
+ return {tag, type, name: info.name, source: info.source, issues, notes};
+ });
+ const bad = rows.filter((row) => row.issues.length > 0).length + general.length;
+ const choose = (value) => { setWhich(value); setHtml(formSamples[value]); };
+ return (
+
+
+ Which form do you want to check?
+ choose("messy")} /> A messy form
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} fields found, {bad} problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + (row.tag === "input" ? " type=" + row.type : "") + ">"} {row.name ? {'"' + row.name + '"'} : no name }{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+ {general.length > 0 ? (
+
+
Problems with the form as a whole:
+
+ {general.map((item, index) => ({item} ))}
+
+
+ ) : null}
+
+ This reads the HTML only. It cannot judge whether a label is clear, whether errors are helpful, or how the form behaves with a keyboard. Treat it as a first pass, and then test the real form.
+
+
+ );
+}
+
+export function FormChecker() {
+ return {() => } ;
+}
+
+Forms are where websites ask something of people: sign up, check out, book, apply, contact. If a form is hard to use, **people cannot finish,** and the cost is real: a lost order, a missed appointment, a benefit that is never claimed. Forms are also where small accessibility details matter most, because a missing label or a vague error can stop someone completely.
+
+The HTML form controls (`input`, `select`, `textarea`, `button`) are accessible **by default.** Most form accessibility is just **using them properly:** labelling every field, grouping related ones, explaining what is needed, and telling people clearly when something goes wrong.
+
+:::info
+This lesson looks at forms through the lens of the accessibility section: names, focus, errors, and WCAG. If you want the full tour of form elements and attributes, and a step-by-step accessible form, see [Accessible HTML Forms](/tutorials/html/forms/accessible-forms) in the Forms section.
+:::
+
+
+
+
+## What You Will Learn
+
+- How to **label** every field, and why placeholders are not labels
+- How to **group** related fields with `fieldset` and `legend`
+- How to add **hints** and mark **required** fields
+- Choosing the right **input type** and **`autocomplete`**
+- Writing **error messages** that help
+- **When** to validate, and why it matters
+- Keeping **sign-in** and **long forms** easy
+- How to **check** a form
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+ Join the bakery newsletter
+
+
+ Full name (required)
+
+
+
+
+ Email address (required)
+ We only use it to send your receipt.
+
+
+
+
+ How often would you like to hear from us?
+ Weekly
+ Monthly
+
+
+ Subscribe
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Change the settings one at a time, and watch both the HTML and the spoken line. Try "A placeholder only" and "Nothing" to see what goes missing.
+
+## Labels: Every Field Needs One
+
+Every form field needs a **visible, connected `label`.** A label gives the field its accessible name (see [Accessible Names](./accessible-names)), and it also makes the **click area bigger:** clicking the label focuses the field, and toggles a checkbox or radio button. That helps people with tremors, and anyone on a phone.
+
+```html
+
+Email address
+
+
+
+
+ Email address
+
+
+```
+
+- **`for` must match an `id`,** and ids must be unique on the page.
+- **Keep the label visible,** and close to its field. Putting it **above** the field works best on small screens and when zoomed in.
+- **Do not use a placeholder as a label.** It vanishes as you type, so people forget what to enter. It is often low-contrast, and not all tools treat it as a name. Use it, if at all, for a short **example** such as `name@example.com`.
+- **Do not use `title` or `aria-label`** instead of a visible label, unless the purpose is obvious from context. A search box next to a "Search" button is a fair exception.
+- **Labels should be short and specific:** "Email address," not "Please enter your email address here."
+
+## Group Related Fields
+
+When several fields answer **one question,** such as a set of radio buttons or an address, group them with **`fieldset`** and give the group a name with **`legend`:**
+
+```html
+
+ Delivery method
+ Standard
+ Express
+
+```
+
+A screen reader reads the legend **with each option:** "Delivery method, Standard, radio button, 1 of 2." Without it, a person hears only "Standard," and has to guess what it is a choice **of.**
+
+Use groups for radio buttons, **sets of related checkboxes,** and blocks of connected fields (such as "Billing address"). Do **not** wrap everything in a `fieldset`. One for a whole form of unrelated fields adds noise. Keep legends short, because they are read many times.
+
+## Hints, Instructions, and Required Fields
+
+People should know **what is needed before they type,** not after they make a mistake.
+
+- **Put instructions before the field,** or attach them as hints.
+- **Connect hints with `aria-describedby`,** so a screen reader reads them after the name. (You will learn more in [aria-describedby](./aria-describedby).)
+- **Tell people which fields are required,** in words. An asterisk alone is not enough: many assistive tools do not announce it, and its meaning must be explained. Writing **"(required)"** in the label is clear to everyone. Another approach is to say once at the top that all fields are required, and mark the **optional** ones instead.
+- **Add the `required` attribute,** so browsers and assistive technology know it as well. (`aria-required` is not needed on a native field that already has `required`.)
+- **Show formats up front:** "Date, as day/month/year" **before** the field, not in an error afterwards.
+- **Do not rely on colour alone** to mark required fields or errors.
+
+:::info
+Browsers show their own built-in error bubbles for fields with `required` or `type="email"`. They are useful, but the wording and the look differ by browser and language, and are hard to style. Many teams add `novalidate` to the form, and show their **own** messages in the page, as in the lessons below. If you do that, you must do it properly: the message must be text on the page, connected to the field.
+:::
+
+## The Right Type and `autocomplete`
+
+Picking the right **type** changes what people get. On a phone, `type="email"` shows a keyboard with the @ sign, `type="tel"` shows a number pad, and `type="url"` shows a slash and a `.com` key. Browsers can also check the basic format.
+
+**`autocomplete`** tells the browser, and password managers, **what a field is for,** so they can fill it in. This is a WCAG requirement (**1.3.5 Identify Input Purpose**), and it matters hugely to people who find typing hard, who have memory or cognitive difficulties, or who use assistive technology.
+
+| Field | `type` | `autocomplete` |
+| :-- | :-- | :-- |
+| Full name | `text` | `name` |
+| Email | `email` | `email` |
+| Phone | `tel` | `tel` |
+| New password | `password` | `new-password` |
+| Current password | `password` | `current-password` |
+| One-time code | `text` | `one-time-code` |
+| Street address | `text` | `street-address` |
+| Postal code | `text` | `postal-code` |
+| Country | `select` | `country-name` |
+
+- **Never turn `autocomplete` off** for personal details or passwords. It blocks autofill and password managers, and makes forms harder, and passwords weaker.
+- **Use `type="number"` carefully.** It adds spinner buttons, changes values with the mouse wheel, and does not suit things like phone or card numbers. For digits that are not a quantity, use `type="text"` with `inputmode="numeric"`.
+- **Do not block pasting.** People use password managers, and some people cannot type a long code accurately.
+
+## Errors That Help
+
+When something goes wrong, the message should tell people **what is wrong,** **where,** and **how to fix it:**
+
+- **Say it in text,** not only with a red border or an icon (WCAG 1.4.1 and 3.3.1).
+- **Be specific and kind:** "Enter an email address that includes an @ sign," not "Invalid input."
+- **Suggest a fix** when you can (WCAG 3.3.3): "Did you mean name@gmail.com?"
+- **Connect the message to the field** with `aria-describedby`, and set **`aria-invalid="true"`.**
+- **Keep what the person typed.** Do not clear the form after an error.
+- **Tell people straight away** when a form fails: move focus to an **error summary** that links to each problem, or to the first invalid field. See [Focus Management](./focus-management).
+- **Start the message with a word such as "Error:"**, so it is clear without colour.
+
+```html
+Email address (required)
+
+Error: Enter an email address that includes an @ sign.
+```
+
+## When to Validate
+
+Checking a field too early can feel like being **nagged while you are still typing.** For a screen reader user, a message that is announced after every key is overwhelming. The lab below shows the difference.
+
+
+
+
+```html title="index.html"
+Email address
+
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Good habits for timing:
+
+- **Wait** until the person **leaves** the field, or presses Submit, before showing a first error.
+- **Remove the error as soon as it is fixed,** while they type. People then know it worked.
+- **Do not interrupt text entry.** For languages that use an input method editor (such as Chinese, Japanese, or Korean), never validate in the middle of composing a character.
+- **Use a polite live region** (`aria-live="polite"`) for messages that appear **while** someone is in a field, and an error summary (with focus) for a failed submit. See [ARIA Live Regions](./aria-live).
+
+## Help People Avoid Mistakes
+
+WCAG 2.2 has a few more rules that make forms kinder, especially for people with memory, attention, or cognitive differences:
+
+- **Review before you finish (3.3.4):** for payments, legal agreements, and anything that changes data, let people **check, confirm, or undo** before the final submit.
+- **Do not ask twice (3.3.7 Redundant Entry):** if you already know something from earlier in the same process (such as a delivery address), fill it in again, or offer "same as delivery address."
+- **Accessible sign-in (3.3.8):** do not require people to **remember** or **transcribe** something to log in (such as solving a puzzle, or retyping a code from memory). Allow **paste,** support **password managers,** and offer an alternative to anything difficult.
+- **Give enough time:** if a form times out, warn people and let them extend it. Save their progress where you can.
+
+## Other Controls and Layout
+
+- **Choose native controls:** `select`, `input type="checkbox"`, `type="radio"`, and `textarea`. They have keyboard support and screen reader support already. A custom dropdown must be rebuilt with great care.
+- **Dates:** a native `input type="date"` is accessible in most browsers, but its look varies. Three separate, clearly labelled fields for day, month, and year can be easier for known dates, such as a birth date.
+- **File uploads:** say which types and sizes are allowed **before** the upload. After upload, say what was chosen.
+- **Character counters:** do not announce every character. Announce when a limit is near, and when it is reached.
+- **One column,** with **labels above** the fields, works best on phones and when zoomed. The reading order must match the visual order.
+- **Make the submit button clear,** and put it **last.** Name it with a specific action: "Place order," not "Submit."
+- **Check at 400 percent zoom.** The form must reflow, with no horizontal scrolling and no hidden fields.
+
+## Check Your Forms
+
+The checker below reads a snippet of HTML and flags fields with no label, placeholder-only fields, mismatched label ids, wrong input types, missing or disabled `autocomplete`, blocked paste, ungrouped radio buttons, and invalid fields with no message.
+
+
+<>
+
+>
+
+
+Then test the **real** form by hand:
+
+1. **Click each label.** Does the field get focus?
+2. **Tab through** the whole form, in order. Is every field reachable, with a visible focus?
+3. **Submit it empty.** Do you learn what is wrong, where, and how to fix it?
+4. **Fill it with a keyboard only,** including the submit.
+5. **Use a screen reader** on one pass. Are names, groups, hints, and errors announced?
+6. **Zoom to 200 and 400 percent.**
+7. **Try autofill and a password manager.**
+
+## Good Habits
+
+1. **Label every field** with a visible, connected `label`.
+2. **Group related fields** with `fieldset` and `legend`.
+3. **Say what is required,** in words, and give formats before the field.
+4. **Use the right `type`** and **`autocomplete`.**
+5. **Write errors that say what, where, and how to fix.**
+6. **Validate when people leave a field,** not on every key.
+7. **Keep what people have typed,** and never clear the form after an error.
+8. **Allow paste,** and support password managers.
+9. **Ask only for what you need,** and never ask twice.
+10. **Test with a keyboard, a screen reader, and a phone.**
+
+## Accessibility Checklist
+
+- [ ] Every field has a visible, connected label.
+- [ ] Related fields are in a `fieldset` with a `legend`.
+- [ ] Required fields are marked in text, and have the `required` attribute.
+- [ ] Hints and formats are shown before the field, and connected with `aria-describedby`.
+- [ ] Each field has a suitable `type` and `autocomplete` value.
+- [ ] Errors are shown in text, say how to fix the problem, and are linked to the field.
+- [ ] After a failed submit, focus moves to an error summary or the first problem.
+- [ ] Errors do not appear while someone is still typing.
+- [ ] Paste is allowed, and nothing in sign-in needs memory or a puzzle.
+- [ ] People can review, confirm, or undo important submissions.
+- [ ] The form works with a keyboard, and at 400 percent zoom.
+
+## Security and Privacy Notes
+
+- **Ask for as little as you can.** Every field is data you must protect, and a burden for the person. Never ask for sensitive information (such as health, religion, or ID numbers) unless it is necessary.
+- **Never put sensitive data in the URL.** Use `method="post"` for passwords and personal details. A `GET` form puts values in the address, history, and logs.
+- **Do not disable `autocomplete` for passwords.** Password managers make people safer. Use `new-password` and `current-password`, and `one-time-code` for codes.
+- **Do not block paste** in password or code fields. It pushes people to use weaker, easier passwords.
+- **Be careful with error messages at sign-in.** Saying "No account with that email" reveals who has an account. Many sites say "The email or password is incorrect." Weigh the privacy gain against the added confusion, and explain it in the page.
+- **Anti-spam traps ("honeypots")** are hidden fields that real people should not fill in. If you use one, hide it from **assistive technology too** (`aria-hidden="true"` and `tabindex="-1"`), or screen reader users will fill it in, and be treated as bots.
+- **CAPTCHAs** can block people who are blind, have low vision, or have cognitive differences. Prefer invisible checks, or offer an accessible alternative, and check what data a third-party service collects.
+- **Consent boxes** (marketing, cookies) must never be pre-ticked, and should be separate from the terms.
+- **Use HTTPS** for every page with a form, not only the page that receives it.
+
+## Global Audience Notes
+
+- **Do not force a first name and last name.** Many people have one name, several, or a different order. One "Full name" field is often best, with `autocomplete="name"`.
+- **Addresses differ** by country: some have no postal code, no state, or an unusual order. Make fields optional where you can, or change them by country.
+- **Phone numbers:** accept spaces, dashes, and the `+` country code. Do not demand one local format.
+- **Dates and numbers:** formats vary (day-month-year, month-day-year, decimal points and commas). Show an example and accept sensible forms, or use a native date control.
+- **Right-to-left languages:** mirror the layout, and use `dir="auto"` or `dir="ltr"` for email, URL, and number fields, so that Latin text does not scramble.
+- **Translate everything:** labels, hints, errors, and `aria-label` values. Leave room for longer text, and avoid fixed-width labels.
+- **Input methods:** people who type with an input method editor need freedom to compose text. Validate after the text is committed, not during.
+- **Avoid assumptions** in titles, gender options, or required "marital status." Provide an open text field, or "prefer not to say," and only if you truly need to ask.
+- **Slow or unreliable connections:** keep entered data if a submit fails, and show clear progress.
+
+## Common Mistakes to Avoid
+
+1. **Fields with only a placeholder.**
+2. **Labels that are not connected** (a missing or mismatched `for` and `id`).
+3. **Radio buttons with no `fieldset` and `legend`.**
+4. **Marking required fields only with a red asterisk.**
+5. **Error messages that say only "Invalid."**
+6. **Showing an error on every key press.**
+7. **Clearing the form after an error.**
+8. **`autocomplete="off"` on names, emails, and passwords.**
+9. **Blocking paste.**
+10. **Using `type="text"` for email and phone.**
+11. **Submit buttons that just say "Submit."**
+12. **Never testing with a keyboard or a screen reader.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the field builder to build all four field types, and read the spoken lines.
+2. In the timing lab, type a valid email in each mode, and count the messages.
+3. Run the checker on the messy form, fix every problem, and run it again.
+4. Take a form you have built, and click every label. Does each field get focus?
+5. Wrap a group of radio buttons in a `fieldset` and `legend`, and compare how it looks and sounds.
+6. Add `autocomplete` values to a sign-up form, and test autofill.
+7. Replace a placeholder-only field with a visible label and a hint.
+8. Write three error messages that say what, where, and how to fix.
+9. Add an error summary that links to each invalid field. (See [Focus Management](./focus-management).)
+10. Bonus: fill in a long form using only the keyboard, and note every moment where you got stuck.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. Why is a placeholder not a good label?
+
+It disappears when you type, usually has low contrast, and is only a weak fallback name. Use a visible `label` element.
+
+
+
+
+2. When do you use `fieldset` and `legend`?
+
+For groups of related fields that answer one question, such as radio buttons, related checkboxes, or an address. The legend is read with each option.
+
+
+
+
+3. How do you attach a hint to a field?
+
+Give the hint text an `id`, and put that id in the field's `aria-describedby`. The screen reader reads it after the name.
+
+
+
+
+4. Why use the right `type` and `autocomplete`?
+
+The right type gives the right keyboard and basic checks, and `autocomplete` lets browsers and password managers fill the field. It is also a WCAG requirement (1.3.5).
+
+
+
+
+5. What makes a good error message?
+
+It is text (not only colour), says what is wrong and how to fix it, is connected to the field with `aria-describedby` and `aria-invalid`, and keeps what the person typed.
+
+
+
+
+6. When should a first error appear?
+
+Usually when the person leaves the field, or presses Submit, and not on every key press. Remove the error as soon as it is fixed.
+
+
+
+## Key Takeaways
+
+- **Every field needs a visible, connected label.** A placeholder is not one.
+- **Group** related fields with `fieldset` and `legend`.
+- **Tell people what is needed before they type:** required fields, formats, and hints.
+- **Use the right `type` and `autocomplete`.** Never block paste or autofill.
+- **Errors must say what, where, and how to fix it,** in text, linked to the field.
+- **Validate after people leave a field,** and keep their data when something fails.
+- **Make sign-in and long forms forgiving:** review steps, no repeat entry, no puzzles.
+- **Test the real form** with a keyboard, a screen reader, and zoom.
+
+Next, learn how to make data tables that make sense to everyone in [Accessible Tables](./accessible-tables).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/accessible-links.mdx b/tutorials/html/13-accessibility/accessible-links.mdx
index e69de29bb..a2fa2e261 100644
--- a/tutorials/html/13-accessibility/accessible-links.mdx
+++ b/tutorials/html/13-accessibility/accessible-links.mdx
@@ -0,0 +1,627 @@
+---
+id: html-accessible-links
+title: "Accessible Links"
+sidebar_label: "Accessible Links"
+sidebar_position: 10
+slug: /html/accessibility/accessible-links
+description: "Learn how to build links that everyone can understand and use: meaningful link text, links versus buttons, keyboard and focus, visual styling, links that open new tabs, file and email links, and safe external links. Includes a links-list lab, a link builder, and a live link checker."
+keywords:
+ - accessible links
+ - link text
+ - click here
+ - read more
+ - links vs buttons
+ - target blank
+ - WCAG 2.4.4 link purpose
+ - screen reader links list
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, links, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const vaguePattern = /^(click here|click|here|read more|more|learn more|link|this link|this|details|continue|go|more info|more information)\.?$/i;
+
+export const linkItems = [
+ {title: "Sourdough", blurb: "A tangy loaf with a crackly crust.", bad: "Read more", good: "Read the sourdough recipe"},
+ {title: "Rye", blurb: "Dark, dense, and a little sweet.", bad: "Read more", good: "Read the rye recipe"},
+ {title: "Focaccia", blurb: "Soft, oily, and full of herbs.", bad: "Read more", good: "Read the focaccia recipe"},
+ {title: "Baguette", blurb: "Thin, crisp, and best on the day.", bad: "Read more", good: "Read the baguette recipe"},
+ {title: "Newsletter", blurb: "Get one new recipe in your inbox every week.", bad: "click here", good: "Join the newsletter"},
+ {title: "Printable menu", blurb: "Our full menu, ready to print.", bad: "Download", good: "Download the menu (PDF, 1 MB)"},
+];
+
+export function LinkListLab() {
+ const [fixed, setFixed] = useState(false);
+ const [view, setView] = useState("page");
+ const names = linkItems.map((item) => (fixed ? item.good : item.bad));
+ const counts = {};
+ names.forEach((name) => { counts[name.toLowerCase()] = (counts[name.toLowerCase()] || 0) + 1; });
+ const sorted = names.slice().sort((a, b) => a.localeCompare(b));
+ const repeated = names.filter((name) => counts[name.toLowerCase()] > 1).length;
+ const stop = (event) => event.preventDefault();
+ return (
+
+
+ Choose how to look at the same page
+ setView("page")} /> As it looks on the page
+ setView("list")} /> As a list of links only
+
+
+ setFixed(event.target.checked)} /> Use clear link text
+
+ {view === "page" ? (
+
+ {linkItems.map((item, index) => (
+
{item.title}. {item.blurb} {names[index]}
+ ))}
+
+ ) : (
+
+
Links on this page, in alphabetical order:
+
+ {sorted.map((name, index) => (
+ {name}{counts[name.toLowerCase()] > 1 ? " (one of " + counts[name.toLowerCase()] + " with the same name: which is which?)" : ""}
+ ))}
+
+
+ )}
+
{repeated} of {names.length} links have a name that is shared with another link.
+
+ Many screen readers can show or jump through a list of all the links on a page, without the text around them. The exact keys and the look differ, but the idea is the same. This lab shows why every link name must still make sense on its own. (The links here do not go anywhere, so you can try them safely.)
+
+
+ );
+}
+
+export const kindDefaults = {
+ page: {value: "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/recipes/sourdough", text: "Read the sourdough recipe"},
+ external: {value: "https://example.org/guide", text: "Read the bread science guide"},
+ file: {value: "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/downloads/menu.pdf", text: "Download the menu"},
+ email: {value: "hello@example.com", text: "Email hello@example.com"},
+ phone: {value: "+1 555 010 1234", text: "Call us on +1 555 010 1234"},
+};
+
+export function escapeAttr(value) {
+ return value.replace(/&/g, "&").replace(/"/g, """).replace(/ {
+ setKind(next);
+ setText(kindDefaults[next].text);
+ setValue(kindDefaults[next].value);
+ if (next === "email" || next === "phone") setNewTab(false);
+ };
+ let href = value.trim();
+ if (kind === "email") href = "mailto:" + href.replace(/^mailto:/i, "");
+ if (kind === "phone") href = "tel:" + href.replace(/^tel:/i, "").replace(/[\s()-]/g, "");
+ const canOpenNew = kind !== "email" && kind !== "phone";
+ const attrs = ['href="' + escapeAttr(href) + '"'];
+ if (kind === "file") attrs.push("download");
+ if (newTab && canOpenNew) { attrs.push('target="_blank"'); attrs.push('rel="noopener"'); }
+ const extras = [];
+ if (newTab && canOpenNew && warn) extras.push("opens in a new tab");
+ if (kind === "file" && fileInfo.trim()) extras.push(fileInfo.trim());
+ const label = text.trim() + (extras.length > 0 ? " (" + extras.join(", ") + ")" : "");
+ const code = "" + escapeBody(label) + " ";
+ const notes = [];
+ if (text.trim() === "") notes.push("The link has no text, so it has no name.");
+ if (vaguePattern.test(text.trim())) notes.push("This text is too vague. Say what the person will get.");
+ if (/^(https?:\/\/|www\.)/i.test(text.trim())) notes.push("A web address as link text is tiring to listen to. Use words.");
+ if (text.trim().length > 100) notes.push("Very long. Keep link text to a short phrase.");
+ if (newTab && canOpenNew && !warn) notes.push("This opens a new tab without saying so, which can confuse people who cannot see the screen change.");
+ if (kind === "file" && !fileInfo.trim()) notes.push("Say the file type and size, so people can decide before downloading.");
+ if (kind === "external" && !newTab) notes.push("Opening in the same tab is usually the best choice. People can open a new tab themselves.");
+ if (kind === "email") notes.push("Show the address in the text, so people who cannot open an email app can still copy it.");
+ if (kind === "phone") notes.push("Use the international format with the + and country code, so the number works everywhere.");
+ if (notes.length === 0) notes.push("This looks good.");
+ return (
+
+
+ Where does the link go?
+ changeKind(event.target.value)}>
+ Another page on this site
+ Another website
+ A file to download
+ An email address
+ A phone number
+
+
+
+ Link text
+ setText(event.target.value)} style={{width: "100%"}} />
+
+
+ {kind === "email" ? "Email address" : kind === "phone" ? "Phone number" : "Address (URL or path)"}
+ setValue(event.target.value)} style={{width: "100%"}} />
+
+ {kind === "file" ? (
+
+ File type and size
+ setFileInfo(event.target.value)} />
+
+ ) : null}
+ {canOpenNew ? (
+
+ setNewTab(event.target.checked)} /> Open in a new tab
+ {newTab ? setWarn(event.target.checked)} /> Say so in the link text : null}
+
+ ) : null}
+
HTML:
+
{code}
+
A screen reader may say: {'link, "' + label + '"'}
+
Feedback:
+
+ {notes.map((note, index) => ({note} ))}
+
+
+ );
+}
+
+export const linkSamples = {
+ messy: 'To get recipes, click here .
\nSourdough: Read more
\nRye: Read more
\n \nContact us \nhttps://example.com/very/long/path?id=12345 \nOur partner \nOpen menu \nDownload ',
+ fixed: 'To get weekly recipes, join the newsletter .
\nSourdough: Read the sourdough recipe
\nRye: Read the rye recipe
\n \nContact us \nBread glossary \nOur partner (opens in a new tab) \nOpen menu \nDownload the menu (PDF, 1 MB) ',
+};
+
+export function linkTextOf(node) {
+ if (node.nodeType === 3) return node.textContent;
+ if (node.nodeType !== 1) return "";
+ if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) return "";
+ if (node.tagName.toLowerCase() === "img") return node.getAttribute("alt") || "";
+ let out = "";
+ node.childNodes.forEach((child) => { out += " " + linkTextOf(child) + " "; });
+ return out;
+}
+
+export function LinkCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(linkSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const links = Array.from(doc.querySelectorAll("a"));
+ const rows = links.map((a) => {
+ const label = (a.getAttribute("aria-label") || "").trim();
+ const name = (label || linkTextOf(a)).replace(/\s+/g, " ").trim();
+ return {a, name, href: a.getAttribute("href"), issues: []};
+ });
+ const byName = {};
+ rows.forEach((row) => {
+ if (row.name && row.href) {
+ const key = row.name.toLowerCase();
+ byName[key] = byName[key] || {};
+ byName[key][row.href] = true;
+ }
+ });
+ rows.forEach((row) => {
+ const a = row.a;
+ if (row.href === null) row.issues.push("No href, so it is not a real link: it cannot be reached with Tab. Add an href, or use a button.");
+ else if (row.href.trim() === "#" || row.href.trim() === "" || /^javascript:/i.test(row.href.trim())) row.issues.push("A fake link (# or javascript:). If it does an action, use a button.");
+ if (!row.name) row.issues.push("No accessible name.");
+ else if (vaguePattern.test(row.name)) row.issues.push("Vague text. Say where the link goes.");
+ if (/^(https?:\/\/|www\.)/i.test(row.name)) row.issues.push("A web address as link text is hard to listen to. Use words.");
+ if (row.name.length > 100) row.issues.push("Very long link text.");
+ if (a.getAttribute("target") === "_blank") {
+ if (!/new (tab|window)/i.test(row.name)) row.issues.push("Opens a new tab without saying so.");
+ if (!/noopener/i.test(a.getAttribute("rel") || "")) row.issues.push("Add rel=\"noopener\" (modern browsers add it for you, but older ones may not).");
+ }
+ if (row.name && row.href && Object.keys(byName[row.name.toLowerCase()]).length > 1) row.issues.push("Same name as another link with a different destination.");
+ if (a.hasAttribute("download") && !/\(.*\d.*\)/.test(row.name)) row.issues.push("A download link should say the file type and size.");
+ });
+ const bad = rows.filter((row) => row.issues.length > 0).length;
+ const choose = (value) => { setWhich(value); setHtml(linkSamples[value]); };
+ return (
+
+
+ Which page do you want to check?
+ choose("messy")} /> A messy page
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} links found, {bad} with problems.
+
+ {rows.map((row, index) => (
+
+ {row.name ? {'"' + (row.name.length > 50 ? row.name.slice(0, 50) + "..." : row.name) + '"'} : no name }{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+
+ ))}
+
+
+ This checks the HTML only, and nothing is loaded or followed. It cannot tell whether a name is truly clear, or whether a link goes where it says. Read each name and ask: would this make sense on its own?
+
+
+ );
+}
+
+export function LinkChecker() {
+ return {() => } ;
+}
+
+Links are the **threads** of the web. Everything people do online, from reading an article to paying a bill, depends on following links. Yet links are also one of the places where accessibility most often goes wrong: "click here" text that means nothing out of context, buttons dressed up as links, links that cannot be reached with a keyboard, and links that open new tabs without warning.
+
+The good news: the `a` element is **accessible by default.** It can be focused, activated with Enter, announced as a "link," and listed by screen readers. Your job is to **not break that,** and to give every link a **clear name** and a **clear look.**
+
+
+
+
+## What You Will Learn
+
+- How to write **link text** that makes sense on its own
+- When to use a **link** and when to use a **button**
+- Why a link needs an **`href`**
+- How to make links **visible,** and not rely on colour alone
+- How to handle **new tabs, downloads, email,** and **phone** links
+- How to keep **external links** safe
+- How to **check** your links
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+To get recipes, click here .
+Sourdough: Read more
+Rye: Read more
+
+
+To get weekly recipes, join the newsletter .
+Sourdough: Read the sourdough recipe
+Rye: Read the rye recipe
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Switch the lab to "a list of links only," first with the vague text, and then with clear text. The second list is one you could actually use.
+
+## Link Text That Works
+
+Screen reader users often jump from link to link, or call up a **list of all the links** on a page, **without** the text around them. Sighted users skim too, and voice control users say the link text to click it. So the best link text is meaningful **on its own.**
+
+1. **Say where the link goes,** or what it does: "Read the sourdough recipe," not "click here."
+2. **Avoid "click here," "here," "read more," "more," and "link."** If you must use "Read more," add the subject: "Read more about sourdough."
+3. **Be unique.** Links that go to **different places** should have **different names.**
+4. **Put the important words first.** "Sourdough recipe" is easier to skim than "Read the recipe for sourdough."
+5. **Do not include the word "link."** The screen reader already says it.
+6. **Keep it short,** a phrase and not a sentence.
+7. **Do not use a web address as the link text,** unless the address itself is what you are teaching. A screen reader reads "h t t p s colon slash slash...", letter by letter.
+8. **Link the right words.** Link the **whole phrase** that describes the destination, not one random word, and do not run several links together.
+9. **Use the same name for the same destination,** and different names for different destinations, across the whole site.
+
+:::info
+WCAG success criterion **2.4.4 (Link Purpose, In Context)** allows a link's purpose to be worked out from the **surrounding text** (the sentence, paragraph, or list item). Clear link text is still better, because it works in a links list, and in every other situation too.
+:::
+
+### When "Read more" Is the Design
+
+Sometimes a design needs short repeating text such as "Read more" on every card. You still have options:
+
+```html
+
+Read more about rye bread
+
+
+Read more
+```
+
+Both keep "Read more" visible, and give the link a unique name. In option 2, the name **contains the visible text,** which is required by WCAG 2.5.3 (see [Accessible Names](./accessible-names)). One caution: some automatic translation tools may not translate `aria-label`, so option 1 is often safer for multilingual sites. Test with the tools your audience uses.
+
+## Link or Button?
+
+The question is: **does it take you somewhere, or does it do something?**
+
+| If it... | Use |
+| :-- | :-- |
+| Goes to **another page,** or another place on the page | A link: `a href` |
+| Does an **action** (submit, open a menu, save, delete) | A button: `button` |
+| **Toggles** something open or closed | A button, with `aria-expanded` |
+| Downloads a file | A link, with `download` |
+
+Why does it matter? Because they **behave differently:**
+
+- A **link** is activated with **Enter.** A **button** is activated with **Enter and Space.**
+- A screen reader announces "link" or "button," which sets an expectation: a link **takes you somewhere,** a button **does something.**
+- Links can be opened in a new tab, copied, and bookmarked. Buttons cannot.
+
+```html
+
+Recipes
+Menu
+
+
+Recipes
+Menu
+```
+
+An `a` **without an `href`** is not a link at all: it is not in the Tab order, and it is not announced as a link. And `href="#"` or `href="javascript:..."` are fake links. Use a **real URL** for navigation, and a **button** for actions. The next lesson, [Accessible Buttons](./accessible-buttons), goes further.
+
+## Make Links Easy to See
+
+Links must be recognizable **without relying on colour alone** (WCAG 1.4.1). About one in twelve men has some colour vision difference, and many people use screens in bright sunlight.
+
+- **Keep the underline** in body text. It is the strongest signal that something is a link.
+- If you remove the underline, the link needs a **contrast of at least 3:1** against the surrounding text, **plus** another cue on hover and focus (such as a bold or underline). And the link colour must have **4.5:1** against the background.
+- **Show a clear focus style.** Never remove the outline without a better replacement. See [Keyboard Accessibility](./keyboard-accessibility).
+- **Make hover, focus, and visited states distinct,** but do not depend on colour for the difference between them.
+- **Give links room.** Small, tight links are hard to tap. WCAG 2.2 adds a minimum target size (24 by 24 CSS pixels, with exceptions, including links inside a sentence), but bigger is kinder, especially in lists and menus.
+
+```css
+a {
+ color: #0b5cad;
+ text-decoration: underline;
+ text-underline-offset: 0.15em;
+}
+
+a:hover {
+ text-decoration-thickness: 0.15em;
+}
+
+a:focus-visible {
+ outline: 3px solid #1d4ed8;
+ outline-offset: 3px;
+}
+```
+
+## Links That Open New Tabs
+
+Opening a link in a **new tab or window** (`target="_blank"`) can confuse people: a screen reader user may not realise a new tab has opened, and the Back button no longer works. Many users also **prefer to choose** for themselves, and can already open any link in a new tab.
+
+- **Prefer the same tab** in most cases.
+- If you do open a new tab, **say so** in the link, with text such as "(opens in a new tab)."
+- **Add `rel="noopener"`.** Modern browsers treat `target="_blank"` this way by default, but older ones may not, so it is a safe habit.
+- **Never open new windows** automatically, or without a clear reason, such as a form in the middle of being filled out.
+
+Use the builder below to see how the pieces fit together, and what the feedback says.
+
+## Files, Email, and Phone Links
+
+Links are not just for pages. The link text should always say what will happen:
+
+- **Downloads:** include the **file type and size,** such as "Download the menu (PDF, 1 MB)." People on slow or metered connections can then choose. The `download` attribute only works for same-site files in most browsers.
+- **Email:** use `mailto:`, and show the address in the text so people can copy it if their device has no email app.
+- **Phone numbers:** use `tel:`, with the international format (`+1 555 010 1234`), so it works from anywhere.
+- **Other languages:** when a link goes to a page in another language, say so, and add `hreflang` to the link. If the link text is in another language, mark it with `lang`.
+
+
+
+
+```html title="index.html"
+
+
+ Bread science guide (opens in a new tab)
+
+
+
+Download the menu (PDF, 1 MB)
+
+
+Email hello@example.com
+Call us on +1 555 010 1234
+
+
+Leer en español
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+## Links Inside the Same Page
+
+Links that jump to another part of the **same page** (a table of contents, a "skip to content" link) use a fragment, such as `href="#recipes"`, with a matching `id`:
+
+```html
+Jump to the recipes
+
+Recipes
+```
+
+Browsers usually move keyboard focus to the target as well, but behaviour has differed in the past. Adding `tabindex="-1"` to a **heading** or section that you link to is a safe way to make the next Tab press start from the right place. See [Focus Management](./focus-management).
+
+## Links and Images, and the Current Page
+
+- **An image in a link:** its alt text becomes the link's name, so describe **where the link goes.** See [Alt Text](./alt-text).
+- **Icon-only links** (a social media icon, say) need a name too: `aria-label="Baker's Corner on Instagram"`.
+- **In a navigation menu,** mark the link for the **current page** with `aria-current="page"`. Screen readers say "current page," and you can style it with `a[aria-current="page"]`.
+- **Group related links** in a `nav` (with a name if there are several) and a list, so screen reader users hear "list, 5 items."
+
+```html
+
+
+
+```
+
+## Check Your Links
+
+The checker below reads every `a` in a snippet of HTML, and flags missing hrefs, fake links, vague text, web addresses as text, unannounced new tabs, same-name links with different destinations, and downloads with no file details.
+
+
+<>
+
+>
+
+
+On a real site, you can also:
+
+1. **Press Tab** through the page. Is every link reachable, and clearly marked when focused?
+2. **Use your screen reader's links list,** or the Accessibility panel in DevTools, and read the names without the page.
+3. **Run an automated checker** (such as Lighthouse or axe) for empty links and low contrast.
+4. **Check them with voice control:** say "click" and the link text.
+
+## Good Habits
+
+1. **Write link text that works on its own.**
+2. **Use a real `a href` for navigation,** and a `button` for actions.
+3. **Keep the underline** for links inside text.
+4. **Show a clear focus style.**
+5. **Tell people** when a link opens a new tab, downloads a file, or leaves your site.
+6. **Say the file type and size** for downloads.
+7. **Give identical names to identical destinations,** and different names to different ones.
+8. **Mark the current page** with `aria-current="page"`.
+9. **Check your links** with the keyboard, a links list, and an automated tool.
+
+## Accessibility Checklist
+
+- [ ] Every link has meaningful text, or an accessible name that makes sense on its own.
+- [ ] There are no "click here" or bare "read more" links.
+- [ ] Links with different destinations have different names.
+- [ ] Links are real `a` elements with an `href`, and actions use buttons.
+- [ ] Links can be reached and used with a keyboard, and the focus is clearly visible.
+- [ ] Links are not identified by colour alone, and have enough contrast.
+- [ ] New tabs, downloads, and other surprises are announced in the link text.
+- [ ] Links to files give the type and size.
+- [ ] Links to pages in another language are marked with `hreflang` and `lang`.
+- [ ] The link to the current page is marked with `aria-current="page"`.
+
+## Security and Privacy Notes
+
+- **Keep link text honest.** Do not show a trusted address (`yourbank.com`) as the text of a link that goes somewhere else. It is a common phishing trick, and it can also fool screen reader users, who cannot see the real address on hover.
+- **Use `rel="noopener"`** for `target="_blank"` links, so the new page cannot control your page through `window.opener`. Add `noreferrer` if you do not want to send your address as the referrer, but remember that it also hides where your visitors came from.
+- **Never use `javascript:` links.** They are hard to make accessible, they can be abused, and they are blocked by a strict Content Security Policy.
+- **Do not put private data in URLs.** Tokens, email addresses, and session IDs in a link can be read aloud, saved in browser history, shared by accident, and sent to other sites as referrers. Use POST requests or short-lived tokens instead.
+- **Be careful with `mailto:`.** Addresses in plain HTML are collected by spam bots. Use a contact form if spam is a concern, and tell people if you do.
+- **The `download` attribute** only works for same-origin files in most browsers, so do not rely on it for files hosted elsewhere.
+- **Link to trustworthy sites,** and tell people when they are leaving yours.
+
+## Global Audience Notes
+
+- **Translate link text with the page,** and avoid words like "here" and "more," which translate badly and give no context. A clear phrase survives translation.
+- **Set `lang` and `hreflang`** for links to content in other languages, so voices pronounce them correctly, and people know what they will find.
+- **Web addresses with non-Latin characters** (internationalized domain names) can be read strangely by screen readers. Use words as the link text, and keep the address in the `href`.
+- **Right-to-left languages:** link punctuation, parentheses (such as "(PDF, 1 MB)"), and mixed English words can display in an unexpected order. Test your links in a right-to-left layout.
+- **Slow or costly data:** always give file sizes. A 50 MB download can be expensive for people on mobile data plans.
+- **Phone numbers:** use international format with the country code. Local formats only work for people in your country.
+- **Do not use symbols alone** (arrows, icons) as link names. They can be read oddly in different languages, or not at all.
+
+## Common Mistakes to Avoid
+
+1. **"Click here" and "read more" links.**
+2. **Several different links with the same name.**
+3. **Using `a` with no `href`,** or `href="#"`, for actions.
+4. **Using `div` or `span` with a click handler** as a link.
+5. **Removing the underline,** with no other cue.
+6. **Removing the focus outline.**
+7. **Opening new tabs without saying so.**
+8. **Using a web address as link text.**
+9. **A downloadable file with no type or size.**
+10. **Image links with empty alt text and no other text.**
+11. **Link text that does not match the destination page.**
+12. **Never testing with a keyboard or a links list.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the links list lab with and without clear text. Describe the difference.
+2. Rewrite five "Read more" links on a page you built so each one makes sense alone.
+3. Use the link builder for each of its five kinds, and read the feedback.
+4. Run the checker on the messy page, fix every item, and run it again.
+5. Replace a `span` with an `onclick` by a real link or button, as fits.
+6. Add `aria-current="page"` to your navigation, and style it.
+7. Open a screen reader's links list (or DevTools) on a site you like. Which names are weak?
+8. Remove the underline from your links, then measure the contrast with the surrounding text.
+9. Add a `download` link with the file type and size.
+10. Bonus: Tab through your site with no mouse. Write down every link with weak text or weak focus.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What makes good link text?
+
+It says where the link goes, or what it does, and makes sense on its own, because people often hear or see links without the text around them.
+
+
+
+
+2. When do you use a link, and when a button?
+
+A link takes you somewhere (`a href`). A button does something (submit, open, save, toggle).
+
+
+
+
+3. Why is an `a` without an `href` a problem?
+
+It is not a real link: it is not reachable with Tab, and it is not announced as a link.
+
+
+
+
+4. How should you handle a link that opens a new tab?
+
+Prefer the same tab. If you do open a new one, say so in the link text, and add `rel="noopener"`.
+
+
+
+
+5. Why keep the underline on links in text?
+
+Links must not rely on colour alone to be recognised (WCAG 1.4.1). The underline is the clearest cue.
+
+
+
+
+6. What should a download link tell you?
+
+The file type and size, such as "Download the menu (PDF, 1 MB)."
+
+
+
+## Key Takeaways
+
+- **Links take you somewhere. Buttons do something.** Use the right one.
+- **Link text must make sense out of context.** Avoid "click here" and bare "read more."
+- **Every link needs an `href`,** and a visible name.
+- **Do not rely on colour alone.** Keep the underline, and a clear focus style.
+- **Tell people about surprises:** new tabs, downloads (with type and size), and other languages.
+- **Keep links honest and safe:** use `rel="noopener"`, no private data in URLs, and no misleading text.
+- **Test** by keyboard, with a links list, and with an automated checker.
+
+Next, learn how to build buttons that work for everyone in [Accessible Buttons](./accessible-buttons).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/accessible-names.mdx b/tutorials/html/13-accessibility/accessible-names.mdx
index e69de29bb..3f8db2093 100644
--- a/tutorials/html/13-accessibility/accessible-names.mdx
+++ b/tutorials/html/13-accessibility/accessible-names.mdx
@@ -0,0 +1,681 @@
+---
+id: html-accessible-names
+title: "Accessible Names"
+sidebar_label: "Accessible Names"
+sidebar_position: 8
+slug: /html/accessibility/accessible-names
+description: "Learn what an accessible name is, where browsers get it from (aria-labelledby, aria-label, labels, alt text, content, title), how to write good names, and why the visible label must be part of the name. Includes a live name calculator, a page name audit, and a short drill."
+keywords:
+ - accessible name
+ - accessible name computation
+ - aria-label
+ - aria-labelledby
+ - label in name
+ - WCAG 2.5.3
+ - name role value
+ - screen reader names
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, accessible-name, labels, aria, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const nameSamples = [
+ {label: "Button with text", target: "button", html: 'Save recipe '},
+ {label: "Icon button with no name", target: "button", html: '🔍 '},
+ {label: "Icon button with aria-label", target: "button", html: '🔍 '},
+ {label: "Input with a label", target: "input", html: 'Email address \n '},
+ {label: "Input with only a placeholder", target: "input", html: ' '},
+ {label: "Image with alt text", target: "img", html: ' '},
+ {label: "Image with no alt", target: "img", html: ' '},
+ {label: "Link that says Read more", target: "a", html: 'Sourdough \nRead more '},
+ {label: "aria-labelledby wins", target: "button", html: 'Rye bread \nBuy now '},
+ {label: "Visible text missing from the name", target: "button", html: 'Search '},
+];
+
+export const auditSamples = {
+ messy: '\n Home \n Recipes \n \n\n Privacy \n \nSourdough \nRead more \nRye \nRead more \n🔍 \n \n \n \n',
+ fixed: '\n Home \n Recipes \n \n\n Privacy \n \nSourdough \nRead more \nRye \nRead more \n🔍 \nEmail \n \n \n \n',
+};
+
+export function cleanText(value) {
+ return (value || "").replace(/\s+/g, " ").trim();
+}
+
+export function textOf(node) {
+ if (node.nodeType === 3) return node.textContent;
+ if (node.nodeType !== 1) return "";
+ if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) return "";
+ const tag = node.tagName.toLowerCase();
+ if (tag === "img") return node.getAttribute("alt") || "";
+ if (tag === "svg") {
+ const title = node.querySelector("title");
+ return title ? title.textContent : "";
+ }
+ let out = "";
+ node.childNodes.forEach((child) => { out += " " + textOf(child) + " "; });
+ return out;
+}
+
+export function roleOf(el) {
+ const explicit = el.getAttribute("role");
+ if (explicit) return explicit;
+ const tag = el.tagName.toLowerCase();
+ const type = (el.getAttribute("type") || "text").toLowerCase();
+ if (tag === "a") return el.hasAttribute("href") ? "link" : "generic";
+ if (tag === "button" || tag === "summary") return "button";
+ if (tag === "img") return el.getAttribute("alt") === "" ? "presentation" : "img";
+ if (tag === "nav") return "navigation";
+ if (tag === "select") return "combobox";
+ if (tag === "textarea") return "textbox";
+ if (tag === "iframe") return "iframe";
+ if (/^h[1-6]$/.test(tag)) return "heading";
+ if (tag === "input") {
+ if (type === "checkbox") return "checkbox";
+ if (type === "radio") return "radio";
+ if (type === "submit" || type === "button" || type === "reset" || type === "image") return "button";
+ return "textbox";
+ }
+ return "generic";
+}
+
+export function describeName(el, doc) {
+ const steps = [];
+ const done = (name, source) => ({name, source, steps: steps.concat("Used: " + source)});
+ const tag = el.tagName.toLowerCase();
+ const type = (el.getAttribute("type") || "text").toLowerCase();
+ const ids = cleanText(el.getAttribute("aria-labelledby"));
+ if (ids) {
+ const text = cleanText(ids.split(" ").map((id) => {
+ const target = doc.getElementById(id);
+ return target ? target.textContent : "";
+ }).join(" "));
+ if (text) return done(text, "aria-labelledby");
+ steps.push("aria-labelledby: set, but points to nothing usable");
+ } else {
+ steps.push("aria-labelledby: not set");
+ }
+ const label = cleanText(el.getAttribute("aria-label"));
+ if (label) return done(label, "aria-label");
+ steps.push("aria-label: not set");
+ if (tag === "img" || (tag === "input" && type === "image")) {
+ if (el.hasAttribute("alt")) return done(cleanText(el.getAttribute("alt")), "alt attribute");
+ steps.push("alt: missing");
+ }
+ if (tag === "input" && (type === "submit" || type === "button" || type === "reset")) {
+ const value = cleanText(el.getAttribute("value")) || (type === "submit" ? "Submit" : type === "reset" ? "Reset" : "");
+ if (value) return done(value, "the value (or the browser default)");
+ }
+ if (tag === "input" || tag === "select" || tag === "textarea") {
+ const id = el.getAttribute("id");
+ let text = "";
+ if (id) {
+ const found = Array.from(doc.querySelectorAll("label")).find((item) => item.getAttribute("for") === id);
+ if (found) text = cleanText(found.textContent);
+ }
+ if (!text) {
+ const wrap = el.closest("label");
+ if (wrap) {
+ const copy = wrap.cloneNode(true);
+ copy.querySelectorAll("input, select, textarea").forEach((control) => control.remove());
+ text = cleanText(copy.textContent);
+ }
+ }
+ if (text) return done(text, "a label element");
+ steps.push("label element: none found");
+ }
+ if (tag === "iframe" && cleanText(el.getAttribute("title"))) return done(cleanText(el.getAttribute("title")), "the title attribute (an iframe's native name)");
+ const fromContent = /^(button|a|summary|h1|h2|h3|h4|h5|h6|option)$/.test(tag) || /^(button|link|heading|tab|menuitem)$/.test(el.getAttribute("role") || "");
+ if (fromContent) {
+ const text = cleanText(textOf(el));
+ if (text) return done(text, "the element's own content");
+ steps.push("content: empty (or hidden)");
+ }
+ const title = cleanText(el.getAttribute("title"));
+ if (title) return done(title, "the title attribute (last resort)");
+ steps.push("title: not set");
+ const placeholder = cleanText(el.getAttribute("placeholder"));
+ if (placeholder && (tag === "input" || tag === "textarea")) return done(placeholder, "the placeholder (a weak fallback, not a real label)");
+ steps.push("No source gave a name.");
+ return {name: "", source: "none", steps};
+}
+
+export function NameLabInner() {
+ const [pick, setPick] = useState(0);
+ const [html, setHtml] = useState(nameSamples[0].html);
+ const sample = nameSamples[pick];
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const target = doc.querySelector(sample.target);
+ const result = target ? describeName(target, doc) : null;
+ const role = target ? roleOf(target) : "";
+ const visible = target && /^(button|link)$/.test(role) ? cleanText(textOf(target)) : "";
+ const mismatch = Boolean(result && result.name && visible && result.name.toLowerCase().indexOf(visible.toLowerCase()) === -1);
+ const verdict = !result ? "No matching element found." : result.name === "" ? (role === "presentation" ? "Decorative: the empty name is correct." : "FAIL: no accessible name.") : mismatch ? "WARNING: the visible text is not part of the name." : result.source.indexOf("placeholder") === 0 || result.source.indexOf("the placeholder") === 0 ? "WEAK: only a placeholder." : "OK: this control has a name.";
+ return (
+
+
+ Pick an example
+ { const next = Number(event.target.value); setPick(next); setHtml(nameSamples[next].html); }}>
+ {nameSamples.map((item, index) => ({item.label} ))}
+
+
+
+ HTML (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
+
Tested element: {"<" + sample.target + ">"}
+
Role: {role || "none"}
+
Accessible name: {result && result.name ? {'"' + result.name + '"'} : (none) }
+
Source: {result ? result.source : "none"}
+
Result: {verdict}
+ {result ? (
+
+ How the name was found, step by step
+
+ {result.steps.map((step, index) => ({step} ))}
+
+
+ ) : null}
+
+
+ This is a simplified simulation of the browser's naming rules, not the full specification. Real browsers and screen readers handle many extra cases, and the results can differ slightly. For an image with no alt at all, some screen readers read the file name, which is almost never helpful.
+
+
+ );
+}
+
+export function NameLab() {
+ return {() => } ;
+}
+
+export function NameAuditInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(auditSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const elements = Array.from(doc.querySelectorAll("a[href], button, input:not([type=hidden]), select, textarea, summary, img, iframe, nav"));
+ const navCount = elements.filter((el) => el.tagName.toLowerCase() === "nav").length;
+ const rows = elements.map((el) => {
+ const tag = el.tagName.toLowerCase();
+ const role = roleOf(el);
+ const info = describeName(el, doc);
+ return {el, tag, role, name: info.name, source: info.source, issues: []};
+ });
+ const counts = {};
+ rows.forEach((row) => {
+ if ((row.role === "link" || row.role === "button") && row.name) {
+ const key = row.role + ":" + row.name.toLowerCase();
+ counts[key] = (counts[key] || 0) + 1;
+ }
+ });
+ rows.forEach((row) => {
+ if (row.role === "presentation") return;
+ if (!row.name) {
+ row.issues.push(row.tag === "img" ? "Missing alt text" : row.tag === "nav" ? (navCount > 1 ? "Several nav landmarks, and this one has no name" : "") : "No accessible name");
+ } else if (row.source.indexOf("the placeholder") === 0) {
+ row.issues.push("Only a placeholder, which is not a real label");
+ }
+ if ((row.role === "link" || row.role === "button") && row.name && counts[row.role + ":" + row.name.toLowerCase()] > 1) {
+ row.issues.push("Same name as " + (counts[row.role + ":" + row.name.toLowerCase()] - 1) + " other " + row.role + "(s)");
+ }
+ });
+ const problems = rows.filter((row) => row.issues.filter(Boolean).length > 0);
+ const choose = (value) => { setWhich(value); setHtml(auditSamples[value]); };
+ return (
+
+
+ Which page do you want to check?
+ choose("messy")} /> A messy page
+ choose("fixed")} /> The fixed version
+
+
+ HTML to audit (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{elements.length} items checked, {problems.length} with problems.
+
+ {rows.map((row, index) => {
+ const issues = row.issues.filter(Boolean);
+ return (
+
+ {"<" + row.tag + ">"} ({row.role}): {row.name ? {'"' + row.name + '"'} : no name }{" "}
+ {row.role === "presentation" ? Decorative image. Fine. : issues.length === 0 ? OK : Problem: {issues.join("; ")} }
+
+ );
+ })}
+
+
+ A quick teaching check, not a full audit. Real tools such as the browser's Accessibility panel or an automated checker catch more, and a person testing with a screen reader catches the rest. A name can exist and still be unhelpful, so read each one and ask: does it make sense on its own?
+
+
+ );
+}
+
+export function NameAudit() {
+ return {() => } ;
+}
+
+export const drillQuestions = [
+ {q: "A button shows only a magnifying-glass icon. What should its accessible name be?", options: ["button", "Search", "magnifier.svg"], answer: 1, why: "Name the action, not the picture or the role. Add it with aria-label, or with visually hidden text inside the button."},
+ {q: "A text field has a placeholder that says Email, and no label. Is that enough?", options: ["Yes, the placeholder is the name", "No, use a real label element", "Yes, but only on phones"], answer: 1, why: "Placeholders disappear when you type, usually have weak contrast, and are only a fallback. A visible label element is the right fix."},
+ {q: "A button has aria-label=\"Find recipes\" but the visible text is Search. What is the problem?", options: ["Nothing, aria-label always wins", "The visible text is not in the name, which breaks voice control (WCAG 2.5.3)", "aria-label cannot be used on buttons"], answer: 1, why: "A person who says click Search will not match Find recipes. Start the name with the visible text, such as Search recipes."},
+ {q: "Which source wins if an element has both aria-labelledby and aria-label?", options: ["aria-label", "aria-labelledby", "Whichever comes first in the HTML"], answer: 1, why: "aria-labelledby comes first in the naming order, followed by aria-label."},
+ {q: "A page has two nav landmarks. What do they need?", options: ["Different names, such as Main and Footer", "Nothing, landmarks never need names", "The word navigation in their names"], answer: 0, why: "When there are several of the same landmark, names let people tell them apart. Do not add the role word, because the screen reader already says navigation."},
+ {q: "Three links all say Read more. What is the best fix?", options: ["Make them bold", "Give each a unique name, such as Read more about Rye", "Remove the links"], answer: 1, why: "Screen reader users often browse a list of links out of context, so each name should make sense alone."},
+];
+
+export function NameDrill() {
+ const [answers, setAnswers] = useState({});
+ const score = drillQuestions.filter((item, index) => answers[index] === item.answer).length;
+ const answered = Object.keys(answers).length;
+ return (
+
+ {drillQuestions.map((item, index) => (
+
+ {index + 1}. {item.q}
+ {item.options.map((option, optionIndex) => (
+
+ setAnswers({...answers, [index]: optionIndex})} /> {option}
+
+ ))}
+ {answers[index] !== undefined ? (
+ {answers[index] === item.answer ? "Correct." : "Not quite."} {item.why}
+ ) : null}
+
+ ))}
+
Score: {score} of {drillQuestions.length} ({answered} answered)
+
+ );
+}
+
+Imagine walking into a room where every door, drawer, and switch has **no label**. You could still open things, but you would have to guess what each one does. That is what a page feels like to a screen reader user when controls have **no accessible name.**
+
+An **accessible name** is the short piece of text that assistive technology uses to **call something by its name.** "Save recipe, button." "Email address, edit text." "Close, button." The name is what a screen reader announces, what a voice control user says to click something, and what shows up in lists of links and form fields. It is one of the most important parts of an accessible page, and one of the easiest to get right.
+
+
+
+
+## What You Will Learn
+
+- What an **accessible name** is, and how it differs from a **description**
+- **Where browsers get names from,** and in what order
+- How names work for **buttons, links, form fields, images, landmarks,** and **iframes**
+- Why the **visible label must be part of the name** (WCAG 2.5.3)
+- How to **write good names:** short, unique, and meaningful
+- How to **check names** in your browser
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+Save recipe
+All recipes
+
+
+Email address
+
+
+
+
+
+
+
+ 🔍
+
+
+
+…
+…
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Pick each example in the lab, read the name, then edit the HTML and watch the name change. Try deleting the `aria-label` from the icon button, or adding a `title` to the image.
+
+## What Is an Accessible Name?
+
+Every element in the accessibility tree has a **role** (what it is), a **name** (what it is called), and sometimes a **state** or **value** (what it is doing). Together they tell a person what a control **is** and what it **does.**
+
+- **Role:** `button`
+- **Name:** "Save recipe"
+- **State:** `pressed`, `expanded`, `checked`, and so on
+
+The name is **not** the same as a **description.** A name is short and identifies the thing ("Password"). A description gives **extra help** ("At least 12 characters"). You will learn to add descriptions with `aria-describedby` later in this section.
+
+:::info
+A screen reader reads the name and the role together, in an order that depends on the screen reader. If your name says "Search button," many users will hear "Search button, button." Name the **action,** and let the role speak for itself.
+:::
+
+## Where Names Come From
+
+The browser works out the name from several possible sources, and uses the **first useful one.** This is a simplified version of the order:
+
+| Order | Source | Example |
+| :-- | :-- | :-- |
+| 1 | `aria-labelledby` | `` |
+| 2 | `aria-label` | `` |
+| 3 | The element's **native** label | ``, `alt`, ``, ``, a button's `value` |
+| 4 | The element's **content** (for buttons, links, headings, tabs, menu items) | `Save recipe ` |
+| 5 | The `title` attribute | `` |
+| 6 | The `placeholder` (a weak fallback) | ` ` |
+
+The real rules (called the Accessible Name and Description Computation) have more steps, and browsers apply a few small differences. But this order covers nearly everything you will meet.
+
+:::tip
+**The best name is almost always the one that is already on the screen.** Use real text and real labels first. Reach for `aria-label` only when there is no visible text to use. The next lessons look at `aria-label` and `aria-labelledby` in detail.
+:::
+
+## Names From Content
+
+Buttons, links, headings, tabs, and menu items are named by **what is inside them.**
+
+```html
+Save recipe
+
+
+
+ Sourdough loaf
+
+```
+
+Notice the image in the second example has `alt=""`. It is **decorative** (the text already says it), so it adds nothing to the name. If the image were the **only** content, its alt text would become the link's name, and it would need to describe where the link goes.
+
+An icon with no text is the classic failure:
+
+```html
+
+
+
+
+
+
+
+```
+
+Another good option is **visually hidden text** inside the button. It stays in the page for screen readers, but is not drawn on screen. Your CSS framework probably has a ready-made class for this, often called `sr-only` or `visually-hidden`.
+
+## Names From Labels
+
+Form fields get their names from a `label` element. This is the best way, because the label also makes the **clickable area bigger** and is visible to everyone.
+
+```html
+
+Email address
+
+
+
+
+ Email address
+
+
+```
+
+A **placeholder** is not a label. It vanishes as soon as you type, it often has weak contrast, and many tools treat it only as a last resort. Use it for an **example** of the format (`name@example.com`), never for the label itself. See [Accessible Forms](./accessible-forms) for more.
+
+For a **group** of related fields (such as a set of radio buttons), use `fieldset` with a `legend`. The legend gives the group its name, and a screen reader reads it with each option: "Delivery: Standard, radio button."
+
+## Names for Images
+
+For an `img`, the name is its `alt` text.
+
+- **Informative image:** write a short description of its **purpose or content.**
+- **Decorative image:** use `alt=""`, so assistive technology skips it.
+- **Image that is a link or button:** the alt text should describe **where the link goes or what the button does,** not the picture.
+- **Missing `alt`:** some screen readers read out the **file name,** such as "IMG underscore 4021 dot jpg." Always include the attribute.
+
+You will study this in depth in the next lesson, [Alt Text](./alt-text).
+
+## Names for Landmarks, Frames, and Tables
+
+- **Landmarks:** if you use more than one of the same kind (two `nav` elements, say), give each a **different name** with `aria-label` or `aria-labelledby`. Do not put the role in the name: use "Main," not "Main navigation," because the screen reader already says "navigation."
+- **Iframes:** use the **`title`** attribute to say what the frame holds, for example `title="Map of our bakery"`.
+- **Tables:** give each table a `caption`. See [Accessible Tables](./accessible-tables).
+- **Groups:** use `fieldset` and `legend`, as above.
+
+## Label in Name: Match What People See
+
+WCAG success criterion **2.5.3 (Label in Name)** says that, if a control has a **visible label,** the accessible name must **contain that visible text.**
+
+Why? Because voice control users **say what they see.** If the screen shows "Search," they say "click Search." If the hidden name is "Find recipes," nothing matches, and the command fails. A screen reader user who has sighted colleagues, or who uses speech and sight together, gets confused in the same way.
+
+```html
+
+Search
+
+
+Search
+```
+
+The lab above has an example for this. Try it, and then fix it.
+
+## Writing Good Names
+
+1. **Be short.** A name is a label, not a sentence. Put long help in a description.
+2. **Name the action or purpose,** not the shape ("Close," not "X" or "Cross icon").
+3. **Make it unique.** Several "Read more" links are hard to tell apart in a list of links. Say "Read more about Rye."
+4. **Put the key words first.** Screen reader users often skim by the first word or two.
+5. **Do not repeat the role.** "Close," not "Close button."
+6. **Match the visible text** (see above).
+7. **Do not rely on `title` or a placeholder** for names that matter.
+8. **Do not use the same name for different things,** such as two buttons that both say "Delete" without saying what they delete.
+9. **Keep it in the language of the page,** and translate it with the rest of your text.
+
+## Audit the Names on Your Page
+
+A quick way to find missing names is to list every control and check it. The tool below does this for a snippet of HTML, and flags missing names, placeholders used as labels, duplicate names, and unnamed landmarks.
+
+
+
+
+```html title="index.html"
+
+ Home
+ Recipes
+
+
+ Privacy
+
+
+Sourdough
+Read more
+
+
+ 🔍
+
+
+Email
+
+
+
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Start with the messy page, then switch to the fixed one and compare. Notice that the fixed "Read more" links use `aria-label`, but their visible text ("Read more") is still at the start of the name. That keeps **Label in Name** satisfied.
+
+## Check Names in Your Browser
+
+You do not need a screen reader for a first check:
+
+1. Open your browser's **DevTools,** and select an element.
+2. Find the **Accessibility** panel (in Chromium-based browsers it is next to "Styles," and in Firefox it has its own tab).
+3. Look at the **Name** and **Role.** If the name is empty, you have found a problem.
+4. Many browsers also show **where the name came from,** such as "aria-label" or "contents."
+5. Then, try it with a real screen reader and with voice control ("click Subscribe"), at least on your most important pages.
+
+## Good Habits
+
+1. **Use real text and real labels** first. Add ARIA only when you must.
+2. **Give every control, image, frame, and repeated landmark a name.**
+3. **Name icon-only controls,** and hide the icon itself with `aria-hidden="true"`.
+4. **Make visible text part of the name.**
+5. **Make names unique and meaningful on their own.**
+6. **Keep names short.** Put the extra detail in a description.
+7. **Check the Accessibility panel** whenever you build a new component.
+8. **Test with voice control** (say what you see), not only with a screen reader.
+
+## Accessibility Checklist
+
+- [ ] Every link, button, and form field has an accessible name.
+- [ ] Every form field has a visible `label` (not only a placeholder).
+- [ ] Every informative image has useful alt text, and decorative images use `alt=""`.
+- [ ] Icon-only controls have a name that describes the action.
+- [ ] When the page has more than one of the same landmark, each has a unique name.
+- [ ] Every `iframe` has a `title`, and every data table has a `caption`.
+- [ ] Visible labels are part of the accessible names (WCAG 2.5.3).
+- [ ] Links and buttons that look the same but do different things have different names.
+- [ ] Names do not repeat the role ("Close," not "Close button").
+- [ ] Names are written in the language of the page.
+
+## Security and Privacy Notes
+
+- **Do not put private data in names.** Accessible names can be read aloud, shown in lists, and picked up by browser extensions and screen readers around other people. Do not include account numbers, full email addresses, or health details in an `aria-label` unless it is necessary.
+- **Do not use names to mislead.** A name that says "Cancel" on a button that actually confirms a payment is deceptive, and breaks trust for every user.
+- **Be honest about links that leave your site.** A name such as "Pay now" for a link to another site hides where the person is going. Say so where it matters.
+- **Do not collect assistive technology usage.** Detecting that someone uses a screen reader, to change what you show or to track them, reveals a disability. Avoid it.
+- **Third-party widgets** (chat, ads, embeds) often ship with missing names. Check them, and name the frame with a clear `title`.
+
+## Global Audience Notes
+
+- **Translate your names.** Text in `aria-label`, `alt`, and `title` is part of your content. Make sure your translation process includes these attributes. Some automatic page-translation tools may not translate every attribute, so test with the tools your audience uses.
+- **Set the page language** with `lang` (and use `lang` on any phrase in another language), so screen readers pronounce names correctly.
+- **Right-to-left languages:** the accessible name follows the language of the text. Keep names in the same language as the visible label, and test with a right-to-left layout.
+- **Voice control works in many languages,** and users say the **visible** label in their own language. That is another reason to keep the visible text in the name.
+- **Names can get longer when translated.** Short, clear source names are easier to translate and to hear.
+- **Avoid names that only work in one culture,** such as jokes, idioms, or symbol names ("Hamburger menu"). Use "Menu."
+
+## Common Mistakes to Avoid
+
+1. **Icon buttons with no name.**
+2. **Inputs with only a placeholder.**
+3. **Many links called "Read more" or "Click here."**
+4. **Images with no `alt` attribute.**
+5. **A name that does not contain the visible text.**
+6. **Repeating the role in the name** ("Search button").
+7. **Using `title` as the main label.**
+8. **Several `nav` landmarks with no names.**
+9. **Iframes with no `title`.**
+10. **Using `aria-label` on elements that cannot be named** (for example, a plain `div` or `span` with no role).
+11. **Empty headings, links, or buttons** (for example, ones that only contain an icon font).
+12. **Never looking at the Accessibility panel.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the name lab with every example, and write down the **source** of each name.
+2. Remove the `aria-label` from the icon button, and watch the name disappear.
+3. Fix the "Label in Name" example so the visible text is in the name.
+4. Run the audit on the messy page. Fix every problem, and re-run it.
+5. Paste a form from your own project into the audit, and see which fields lack names.
+6. Open DevTools on a site you use, and find three controls whose names are weak.
+7. Rename three "Read more" links so each one makes sense on its own.
+8. Add a `caption` to a table and a `title` to an iframe, and check them in the Accessibility panel.
+9. Try voice control on your computer or phone, and say the visible name of a button.
+10. Bonus: write the names for a small toolbar of five icon buttons. Can you read them as a list?
+
+## Check Yourself
+
+Choose an answer for each question, and read the explanation.
+
+
+<>
+
+>
+
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What is an accessible name?
+
+The short text that assistive technology uses to identify an element, such as "Save recipe" for a button. It is announced by screen readers and used by voice control.
+
+
+
+
+2. In what order does a browser look for a name?
+
+Roughly: `aria-labelledby`, then `aria-label`, then the native label (label, alt, legend, caption), then the element's content, then `title`, and finally `placeholder` as a weak fallback.
+
+
+
+
+3. How do you name an icon-only button?
+
+Add a name that describes the action, with `aria-label` or visually hidden text, and hide the icon itself from assistive technology with `aria-hidden="true"`.
+
+
+
+
+4. What does "Label in Name" (WCAG 2.5.3) require?
+
+When a control has visible text, the accessible name must contain that text, so that voice control users can say what they see.
+
+
+
+
+5. Why is a placeholder not a good label?
+
+It disappears when typing, often has low contrast, and is treated only as a last-resort name. Use a real `label` element.
+
+
+
+
+6. How should you name two `nav` landmarks on one page?
+
+Give each a different name, such as `aria-label="Main"` and `aria-label="Footer"`, without repeating the word "navigation."
+
+
+
+## Key Takeaways
+
+- An **accessible name** is what a control is **called** by assistive technology, and by voice control.
+- Names come from **aria-labelledby, aria-label, native labels, content, `title`,** and (as a last resort) **placeholders,** roughly in that order.
+- **Use visible text first.** Add ARIA only when there is nothing visible to use.
+- Every **link, button, field, image, frame,** and **repeated landmark** needs a name.
+- **Visible text must be part of the name** (WCAG 2.5.3).
+- Good names are **short, unique, meaningful on their own,** and do not repeat the role.
+- Check names in the **Accessibility panel,** and test with real assistive technology.
+
+Next, learn how to describe images well in [Alt Text](./alt-text).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/accessible-tables.mdx b/tutorials/html/13-accessibility/accessible-tables.mdx
index e69de29bb..bacbda93a 100644
--- a/tutorials/html/13-accessibility/accessible-tables.mdx
+++ b/tutorials/html/13-accessibility/accessible-tables.mdx
@@ -0,0 +1,630 @@
+---
+id: html-accessible-tables
+title: "Accessible Tables"
+sidebar_label: "Accessible Tables"
+sidebar_position: 13
+slug: /html/accessibility/accessible-tables
+description: "Learn how to build data tables that work for screen reader and keyboard users: when to use a table, captions, th and scope, complex tables, empty cells, sortable columns, buttons and checkboxes in rows, and scrollable wide tables. Includes a table reader lab, a scrolling region lab, and a live table checker."
+keywords:
+ - accessible tables
+ - table caption
+ - th scope
+ - table headers
+ - sortable table aria-sort
+ - scrollable table accessibility
+ - screen reader tables
+ - data table
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, tables, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const readerHeaders = ["Loaf", "Price (USD)", "Weight (g)", "In stock"];
+
+export const readerRows = [
+ ["Sourdough", "6.50", "800", "12"],
+ ["Rye", "5.75", "700", "4"],
+ ["Focaccia", "4.25", "500", "9"],
+];
+
+export function TableReaderLab() {
+ const [proper, setProper] = useState(true);
+ const [row, setRow] = useState(0);
+ const [col, setCol] = useState(1);
+ const move = (dr, dc) => {
+ setRow(Math.max(0, Math.min(readerRows.length - 1, row + dr)));
+ setCol(Math.max(0, Math.min(readerHeaders.length - 1, col + dc)));
+ };
+ const value = readerRows[row][col];
+ const colHeader = readerHeaders[col];
+ const rowHeader = readerRows[row][0];
+ const spoken = proper
+ ? (col === 0 ? value + ", row header, row " + (row + 1) + " of " + readerRows.length : value + ". Column: " + colHeader + ". Row: " + rowHeader + ".")
+ : value + ", row " + (row + 2) + ", column " + (col + 1);
+ const cellStyle = (r, c) => ({border: "1px solid #94a3b8", padding: "6px 12px", textAlign: c === 0 ? "left" : "right", background: r === row && c === col ? "#fde68a" : undefined, color: r === row && c === col ? "#0f172a" : undefined, outline: r === row && c === col ? "3px solid #b45309" : undefined, outlineOffset: "-3px"});
+ const headStyle = {border: "1px solid #94a3b8", padding: "6px 12px", textAlign: "left", background: "#e2e8f0", color: "#0f172a"};
+ return (
+
+
+ setProper(event.target.checked)} /> Use real header cells (th with scope)
+
+
+
+ Bread prices and stock
+
+
+ {readerHeaders.map((header, c) => (proper ? {header} : {header} ))}
+
+
+
+ {readerRows.map((cells, r) => (
+
+ {cells.map((cell, c) => (c === 0 && proper ? {cell} : {cell} ))}
+
+ ))}
+
+
+
+
+
+ move(-1, 0)}>↑ {" "}
+ move(1, 0)}>↓ {" "}
+ move(0, -1)}>← {" "}
+ move(0, 1)}>→
+
+
+
A screen reader may say: {spoken}
+
+ Move the highlighted cell with the arrow buttons. With real headers, every cell tells you what it is: the number 6.50 arrives with "Price" and "Sourdough." Without them, you hear a bare number, and must count columns in your head. Screen readers use their own keys to move around tables (they vary), and word it differently, so this is a simplified example.
+
+
+ );
+}
+
+export const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+export function ScrollRegionLab() {
+ const [focusable, setFocusable] = useState(true);
+ const [scrolled, setScrolled] = useState(0);
+ const [focused, setFocused] = useState(false);
+ return (
+
+
+ setFocusable(event.target.checked)} /> Make the scrolling area keyboard focusable (tabindex="0")
+
+
setScrolled(Math.round(event.currentTarget.scrollLeft))} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} style={{overflowX: "auto", maxWidth: "100%", border: "1px solid #94a3b8"}}>
+
+ Loaves sold each month
+
+
+ Loaf
+ {months.map((month) => ({month} ))}
+
+
+
+ {["Sourdough", "Rye", "Focaccia"].map((loaf, r) => (
+
+ {loaf}
+ {months.map((month, c) => ({100 + r * 30 + c * 7} ))}
+
+ ))}
+
+
+
+
Scrolled sideways by: {scrolled} pixels. The scrolling area {focused ? "has focus." : "does not have focus."}
+
+ Press Tab until the table area has a focus ring, then use the left and right arrow keys, and watch the number change. If the box is not focusable, many keyboard users cannot scroll it at all. Some newer browsers make scrollable areas focusable on their own, and others do not, so add tabindex="0" yourself, and test in the browsers your readers use. The area also gets a name from the caption, so a screen reader can announce it as a region.
+
+
+ );
+}
+
+export const tableSamples = {
+ messy: '\n Loaf Price \n Sourdough 6.50 Edit \n Rye 5.75 Edit \n Focaccia Edit \n
\n\n Opening hours \n Open Close \n Mon 8:00 17:00 \n
',
+ fixed: '\n Bread prices and stock \n \n \n Loaf \n Price (USD) \n Actions \n \n \n \n Sourdough 6.50 Edit \n Rye 5.75 Edit \n Focaccia No data Edit \n \n
',
+};
+
+export function TableCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(tableSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const tables = Array.from(doc.querySelectorAll("table"));
+ const reports = tables.map((table, index) => {
+ const issues = [];
+ const notes = [];
+ const caption = table.querySelector("caption");
+ const named = Boolean(caption && caption.textContent.trim()) || Boolean(table.getAttribute("aria-label")) || Boolean(table.getAttribute("aria-labelledby"));
+ const rows = table.querySelectorAll("tr").length;
+ const ths = Array.from(table.querySelectorAll("th"));
+ const role = (table.getAttribute("role") || "").toLowerCase();
+ if (role === "presentation" || role === "none") {
+ if (ths.length > 0 || caption || table.querySelector("thead")) issues.push("Marked as a layout table, but it has data table markup (th, caption, or thead). Remove the role, or the markup.");
+ else notes.push("A layout table. Use CSS grid or flexbox for layout instead.");
+ } else {
+ if (ths.length === 0) issues.push("No header cells. Use th for column and row headings, not bold text in td.");
+ if (!named) issues.push("No caption. Add a caption that names the table.");
+ const noScope = ths.filter((th) => !th.getAttribute("scope") && !th.getAttribute("id"));
+ if (ths.length > 0 && noScope.length > 0) notes.push(noScope.length + " header cell(s) have no scope. Adding scope=\"col\" or scope=\"row\" is the safe choice.");
+ if (ths.some((th) => th.textContent.trim() === "" && !th.querySelector("img[alt]"))) issues.push("A header cell is empty. Add text, or visually hidden text such as Actions.");
+ if (table.querySelector("table")) issues.push("A table inside a table. Split it up, or flatten the layout.");
+ if (table.querySelector("[colspan], [rowspan]") && !table.querySelector("[headers]")) notes.push("Merged cells (colspan or rowspan). Consider splitting into simpler tables, or use headers and id for a complex table.");
+ Array.from(table.querySelectorAll("[headers]")).forEach((cell) => {
+ cell.getAttribute("headers").split(/\s+/).filter(Boolean).forEach((id) => { if (!doc.getElementById(id)) issues.push("A headers value points to the id \"" + id + "\", which does not exist."); });
+ });
+ const empties = Array.from(table.querySelectorAll("td")).filter((td) => td.textContent.trim() === "" && !td.querySelector("img, input, button, select, svg"));
+ if (empties.length > 0) notes.push(empties.length + " empty cell(s). Write something like No data, so nobody wonders if content is missing.");
+ }
+ const names = {};
+ Array.from(table.querySelectorAll("button, a[href]")).forEach((el) => {
+ const name = (el.getAttribute("aria-label") || el.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
+ if (name) names[name] = (names[name] || 0) + 1;
+ });
+ Object.keys(names).forEach((name) => { if (names[name] > 1) issues.push(names[name] + " controls in rows share the name \"" + name + "\". Make each unique, such as Edit Rye."); });
+ Array.from(table.querySelectorAll('input[type="checkbox"], input[type="radio"]')).forEach((input) => {
+ const id = input.getAttribute("id");
+ const labelled = input.getAttribute("aria-label") || input.getAttribute("aria-labelledby") || input.closest("label") || (id && Array.from(doc.querySelectorAll("label")).some((label) => label.getAttribute("for") === id));
+ if (!labelled) issues.push("A checkbox or radio button in a row has no name. Label it, such as Select Rye.");
+ });
+ return {index, rows, caption: caption ? caption.textContent.trim() : "", issues: Array.from(new Set(issues)), notes};
+ });
+ const bad = reports.reduce((sum, report) => sum + report.issues.length, 0);
+ const choose = (value) => { setWhich(value); setHtml(tableSamples[value]); };
+ return (
+
+
+ Which tables do you want to check?
+ choose("messy")} /> Messy tables
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{tables.length} tables found, {bad} problems.
+ {reports.map((report) => (
+
+
Table {report.index + 1}: {report.caption ? {'"' + report.caption + '"'} : no caption }, {report.rows} rows
+ {report.issues.length === 0 && report.notes.length === 0 ?
OK
: null}
+
+ {report.issues.map((item, i) => (Problem: {item} ))}
+ {report.notes.map((item, i) => (Note: {item} ))}
+
+
+ ))}
+
+ This reads the HTML only. It cannot tell whether a caption is clear, whether the data should be a table, or how the table behaves when scrolled or squeezed. Always try it with a keyboard and zoom as well.
+
+
+ );
+}
+
+export function TableChecker() {
+ return {() => } ;
+}
+
+A table shows **relationships.** A sighted person sees a grid, and their eyes quickly travel along a row or down a column to find "the price of rye." A person using a screen reader cannot see the grid. They depend on the **markup** to tell them what each cell means, and which row and column it belongs to. Without that markup, a table becomes a long stream of unconnected numbers.
+
+The good news is that HTML tables are very good at this, when you use them as intended: a `caption` to name the table, `th` cells for headings, and `scope` to say which way they point.
+
+:::info
+This lesson focuses on tables in the **accessibility** section: how assistive technology reads them, and the checks to run. For the complete tour of table elements, `colspan`, `rowspan`, and styling, see [Accessible HTML Tables](/tutorials/html/tables/table-accessibility) in the Tables section.
+:::
+
+
+
+
+## What You Will Learn
+
+- **When** to use a table, and when not to
+- How **captions, `th`,** and **`scope`** give a table meaning
+- How to deal with **complex tables** and **merged cells**
+- How to handle **empty cells,** **sorting,** and **buttons in rows**
+- How to make **wide tables** scroll safely
+- How to **check** your tables
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+ Bread prices and stock
+
+
+ Loaf
+ Price (USD)
+ Weight (g)
+ In stock
+
+
+
+
+ Sourdough
+ 6.50
+ 800
+ 12
+
+
+ Rye
+ 5.75
+ 700
+ 4
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Move around the table with the arrows, once with real headers and once without. Notice how the number 6.50 means nothing alone, and a lot with its two headings.
+
+## Is It a Table?
+
+Use a table for **data with relationships:** rows and columns where each cell depends on **both** its row and its column. Prices by product, opening hours by day, scores by team.
+
+| It is... | Use |
+| :-- | :-- |
+| Data in rows and columns | `table` |
+| A simple list of items | `ul` or `ol` |
+| Page layout, columns, cards | CSS grid or flexbox |
+| Name and value pairs | `dl` (a description list) |
+
+**Never use a table for layout.** Screen readers announce every table, with its rows and columns, and a layout table creates a confusing maze. CSS does the job better, and also adapts to small screens. If an old page must keep a layout table, mark it with `role="presentation"`, and do not put `th` or `caption` in it.
+
+## Caption: The Name of the Table
+
+The `caption` is the **title** of the table. It is announced when a screen reader reaches the table, and it appears in lists of tables, so people can choose which one to read.
+
+```html
+
+ Bread prices and stock
+ …
+
+```
+
+- **Put the `caption` first** inside the `table`.
+- **Make it short and specific:** "Opening hours, October," not "Table 1."
+- **If you need more explanation,** put it in a normal paragraph near the table, and connect it with `aria-describedby="id-of-paragraph"` on the `table`.
+- **Not using a visible caption?** Use `aria-label` or `aria-labelledby` on the table instead, but a visible caption helps everyone.
+
+If the table sits under a heading that already names it, the caption can be a little more detail than the heading, and should not just repeat it.
+
+## Header Cells: `th` and `scope`
+
+Header cells use **`th`,** not `td` with bold text. The **`scope`** attribute says which way a heading points:
+
+- `scope="col"` for a **column** heading (at the top).
+- `scope="row"` for a **row** heading (at the start of a row).
+
+```html
+
+
+ Loaf
+ Price (USD)
+
+
+
+
+ Sourdough
+ 6.50
+
+
+```
+
+- **Group your rows** with `thead`, `tbody`, and (if needed) `tfoot`. It helps browsers, print, and assistive technology.
+- **Put units in the heading,** not in every cell: "Price (USD)" and "Weight (g)."
+- **Make the first column a row header** (`th scope="row"`) when each row is about one thing, such as one product.
+- **The top-left corner cell** is often empty in a table with both row and column headers. Do not leave a `th` empty: put text in it, or visually hidden text such as "Product," so it still has a name.
+
+Modern browsers can often work out the header cells without `scope`, but adding it is a **safe habit** that works everywhere, and costs nothing.
+
+## Complex Tables and Merged Cells
+
+A **simple** table has one row of column headings, and maybe one column of row headings. A **complex** table has headings with several levels, or cells merged with `colspan` and `rowspan`.
+
+Complex tables are hard for assistive technology, and often hard for everyone. Two ideas help:
+
+1. **Simplify first.** Can you split it into two or three simple tables, each with its own caption? That is usually the best accessibility fix.
+2. **If it must stay complex,** connect each cell to its headings by hand: give every `th` an `id`, and list the matching ids in each `td` with the `headers` attribute.
+
+```html
+
+ Loaf
+ March
+
+
+ Rye
+ 120
+
+```
+
+Test complex tables with a screen reader, because support for the `headers` attribute is not always perfect. The full details are in the [Tables section](/tutorials/html/tables/table-accessibility).
+
+## Empty Cells
+
+An **empty cell** leaves people wondering: is the data missing, or is the screen reader skipping it? Say what it means:
+
+- Write **"No data,"** "Not applicable," or "0" if that is the meaning.
+- A dash ("-") is often **read as nothing** or as "dash," so add text if the meaning is not obvious.
+- **Never leave a header cell empty.** If it is just a spacer, use visually hidden text such as "Actions."
+
+## Sortable Columns
+
+If users can sort a table by a column, the column heading must be a **button,** so it can be reached with the keyboard, and `aria-sort` on the `th` says the current order:
+
+```html
+
+ Price (USD)
+
+
+ Weight (g)
+
+```
+
+- Set `aria-sort` to `ascending` or `descending` **only on the column currently sorted.** Remove it from the others.
+- **Do not rely on an arrow icon alone** to show the direction. The text, or `aria-sort`, should say it.
+- **Announce the change** in a polite live region: "Sorted by price, lowest first." See [ARIA Live Regions](./aria-live).
+- **Keep focus on the button** after sorting. See [Focus Management](./focus-management).
+
+## Controls Inside Tables
+
+Rows often hold checkboxes, links, and buttons ("Edit," "Delete"). Each control needs its own name that makes sense on its own (see [Accessible Names](./accessible-names)):
+
+```html
+
+ Rye
+ 5.75
+
+
+ Edit
+
+
+```
+
+If every row says just "Edit," a list of buttons gives the person nothing to tell them apart. Add the row name, as above.
+
+Also **avoid `role="grid"`** unless you are really building a spreadsheet-like widget, with arrow-key cell navigation that you have coded and tested. An ordinary table is better understood, and usually works well.
+
+## Wide Tables and Scrolling
+
+A table with many columns cannot always fit a small screen. The usual fix is to put it in a **scrolling container.** For a keyboard user to scroll it, the container must be **focusable,** and it should have a **name:**
+
+```html
+
+```
+
+```css
+.table-scroll {
+ overflow-x: auto;
+ max-width: 100%;
+}
+```
+
+
+
+
+```html title="index.html"
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+A few more points on small screens:
+
+- **Reflow is the goal,** but WCAG (1.4.10) allows **data tables** to scroll sideways, because breaking the grid would destroy the meaning.
+- **Be careful with "stacked card" designs** that turn rows into blocks with CSS (`display: block`). In some browsers, changing the `display` of table elements has removed their table meaning. Newer versions have improved, but **test with a screen reader.** See [Responsive Tables](/tutorials/html/tables/responsive-tables).
+- **Keep the first column visible** (sticky) if people need to keep their place while scrolling.
+
+## Colour, Contrast, and Zoom
+
+- **Zebra stripes** help the eye, but must not be the only way to tell rows apart. Keep borders or spacing too.
+- **Keep text contrast at 4.5:1** against every row colour, including stripes and hover colours.
+- **Do not use colour alone** to mark meaning (red for "low stock"). Add text, such as "Low."
+- **Align numbers to the right,** so digits line up, and use the same number of decimal places.
+- **Zoom to 200 percent,** and check that the table is still readable, and the header cells are not cut off.
+
+## Check Your Tables
+
+The checker below reads each `table` in a snippet of HTML, and looks for missing headers, missing captions, empty header cells, missing scope, layout tables with data markup, nested tables, bad `headers` references, empty cells, duplicate button names, and unlabelled checkboxes.
+
+
+<>
+
+>
+
+
+Then try the table by hand:
+
+1. **Tab** through the page. Can you reach every control, and scroll any wide table?
+2. **Use a screen reader's table commands** (they differ for each one) to move between cells. Is each cell announced with its headings?
+3. **Open the screen reader's list of tables,** if it has one. Does each table have a meaningful name?
+4. **Zoom to 200 and 400 percent.**
+5. **Print the page,** or view it without CSS. Does the table still make sense?
+
+## Good Habits
+
+1. **Use a table only for data** with rows and columns.
+2. **Give every table a short, specific `caption`.**
+3. **Use `th` with `scope`,** never bold `td` cells.
+4. **Group rows** with `thead` and `tbody`.
+5. **Keep tables simple.** Split complex ones, and use `headers` and `id` only when you must.
+6. **Fill empty cells** with real text such as "No data."
+7. **Name every control in a row** so it is unique.
+8. **Make scrolling areas focusable and named.**
+9. **Test with a screen reader,** a keyboard, and zoom.
+
+## Accessibility Checklist
+
+- [ ] The table holds real data, and is not used for layout.
+- [ ] It has a caption, or an accessible name.
+- [ ] All headings are `th` cells, with `scope` set correctly.
+- [ ] The corner cell and any "actions" column have a name, not an empty header.
+- [ ] Complex tables are simplified, or use `headers` and `id` correctly.
+- [ ] Empty cells say what they mean.
+- [ ] Sortable columns use buttons and `aria-sort`, and announce changes.
+- [ ] Buttons, links, and checkboxes in rows have unique names.
+- [ ] A scrolling table is focusable, named, and keyboard usable.
+- [ ] Contrast is enough, and meaning does not depend on colour alone.
+
+## Security and Privacy Notes
+
+- **Think about who can see the table.** Tables often hold personal data (names, emails, orders, health information). Only show the columns a person is allowed to see, and check access on the server, not only by hiding things in the page.
+- **Do not leave private data in hidden cells,** `aria-label` values, or `data-` attributes. Screen readers, extensions, and anyone viewing the source can read them.
+- **Beware of spreadsheet formula injection.** If people can download a table as a CSV or spreadsheet, a cell that begins with `=`, `+`, `-`, or `@` can run as a formula in some spreadsheet programs. Escape or prefix such values when you export.
+- **Escape user-supplied text** before putting it in a cell, to avoid script injection (cross-site scripting).
+- **Do not make personal data easy to scrape** with an unprotected, unlimited export or "show all rows" feature.
+- **Sorting and filtering done in the browser** still gets all the data. If some of it is confidential, do not send it at all.
+
+## Global Audience Notes
+
+- **Numbers differ.** Some places use a decimal comma ("6,50") and spaces or dots for thousands. Use a formatter that follows the reader's locale, and put the currency or unit in the heading.
+- **Dates:** show them in an unambiguous format (such as 3 Mar 2026), or use a locale-aware formatter.
+- **Right-to-left languages:** add `dir="rtl"` to the page, and the column order mirrors, so the first column appears on the right. Test it, especially sticky columns and scroll direction.
+- **Translated headings are longer.** Do not set fixed widths, and allow wrapping. Short headings in the source translate best.
+- **Abbreviations:** if you use `abbr` for a long heading, write the full meaning in the `title`, and mark foreign words with `lang`. Do not assume everyone knows "qty" or "avg."
+- **Sorting:** alphabetical order differs by language (for example, accented letters). Use locale-aware sorting (`localeCompare`) so the order makes sense to local readers.
+- **Mobile data:** very large tables are heavy on slow connections. Paginate, or load more rows on request, and say clearly how many rows there are.
+
+## Common Mistakes to Avoid
+
+1. **Using a table for page layout.**
+2. **Bold `td` cells instead of `th`.**
+3. **No `caption`, or a caption like "Table 1."**
+4. **Header cells with no text.**
+5. **Missing `scope`,** and no row headers.
+6. **Merged cells in a table that could be split.**
+7. **Empty cells that leave people guessing.**
+8. **Sortable columns that are not buttons.**
+9. **Rows full of buttons that all say "Edit."**
+10. **A scrolling table that a keyboard cannot scroll.**
+11. **Stacked mobile layouts that destroy the table meaning.**
+12. **Never trying the table with a screen reader.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. In the reader lab, walk through all twelve cells with real headers, and then without. Write down the difference.
+2. Run the checker on the messy tables, fix every problem, and run it again.
+3. Turn a table of your own into one with a `caption`, `thead`, `tbody`, and `scope`.
+4. Give the empty corner cell and an "Actions" column visually hidden names.
+5. Add `aria-sort` and sort buttons to a table, with a live region announcing the change.
+6. Make every "Edit" button unique by adding the row name to an `aria-label`.
+7. Put a wide table in a focusable, named scrolling region, and use only the keyboard to read every column.
+8. Replace a layout table with CSS grid.
+9. Split a complex table with merged cells into two simple tables.
+10. Bonus: run a screen reader (or the browser's accessibility panel) over a table, and describe how it announces each cell.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. When should you use a table?
+
+For data where each cell depends on both its row and its column. Not for page layout, which belongs to CSS.
+
+
+
+
+2. What does the `caption` do?
+
+It names the table. It is announced by screen readers, and listed in table lists, so people can find the one they want.
+
+
+
+
+3. What is the difference between `scope="col"` and `scope="row"`?
+
+`scope="col"` says a header applies to the column below it, and `scope="row"` says it applies to the row beside it.
+
+
+
+
+4. How should you handle an empty cell?
+
+Write what it means, such as "No data." Never leave a header cell empty, and use visually hidden text for spacer or action headers.
+
+
+
+
+5. How do you make a column sortable accessibly?
+
+Put a `button` inside the `th`, set `aria-sort` on the sorted column, and announce the change with a live region.
+
+
+
+
+6. Why make a scrolling table container focusable?
+
+So keyboard users can reach it and scroll it with the arrow keys. Give it a name, with a role of `region` and an `aria-labelledby` that points to the caption.
+
+
+
+## Key Takeaways
+
+- **Tables are for data,** and CSS is for layout.
+- **Name the table** with a `caption`.
+- **Use `th` with `scope`,** and group with `thead` and `tbody`, so every cell has its headings.
+- **Keep tables simple.** Split complex ones before you reach for `headers`.
+- **Fill empty cells,** and name every control in a row uniquely.
+- **Sortable columns** need buttons, `aria-sort`, and announcements.
+- **Wide tables** need a focusable, named scrolling region.
+- **Test** with a keyboard, a screen reader, and zoom.
+
+Next, learn what ARIA is, and when to use it, in [Introduction to ARIA](./aria-introduction).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/alt-text.mdx b/tutorials/html/13-accessibility/alt-text.mdx
index e69de29bb..2054736cb 100644
--- a/tutorials/html/13-accessibility/alt-text.mdx
+++ b/tutorials/html/13-accessibility/alt-text.mdx
@@ -0,0 +1,632 @@
+---
+id: html-alt-text
+title: "Alt Text"
+sidebar_label: "Alt Text"
+sidebar_position: 9
+slug: /html/accessibility/alt-text
+description: "Learn how to write good alt text: the five kinds of images (informative, decorative, functional, images of text, complex), how to decide which one you have, how to write short, useful descriptions, and how to handle links, charts, SVG, and figures. Includes a decision guide, an alt text writing practice lab, and a live alt text checker."
+keywords:
+ - alt text
+ - alt attribute
+ - image accessibility
+ - decorative images
+ - alt empty
+ - functional images
+ - complex images
+ - WCAG 1.1.1
+ - screen reader images
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, images, alt-text, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const guideNodes = {
+ start: {question: "Is the image inside a link or a button, or does it do something when clicked?", yes: "functional", no: "text"},
+ text: {question: "Does the image contain words that matter for understanding the page?", yes: "textimage", no: "meaning"},
+ meaning: {question: "Does the image add information that the text around it does not already give?", yes: "detail", no: "decorative"},
+ detail: {question: "Is it a chart, diagram, map, or another image with a lot of detail?", yes: "complex", no: "informative"},
+};
+
+export const guideResults = {
+ functional: {title: "A functional image", advice: "The alt text should say where the link goes, or what the button does. Do not describe the picture.", code: '\n \n \n\n\n \n '},
+ textimage: {title: "An image of text", advice: "Repeat the words exactly. Better still, use real text styled with CSS, so it can be resized, translated, and searched.", code: ' '},
+ decorative: {title: "A decorative image", advice: "Use an empty alt, so assistive technology skips it. Do not leave the attribute out.", code: ' '},
+ informative: {title: "An informative image", advice: "Describe what matters about the image, in the context of the page. Keep it short, and skip phrases like image of.", code: ' '},
+ complex: {title: "A complex image", advice: "Write a short alt that says what the image is and its main point, then put the full detail in the page, such as a table, nearby text, or a details block.", code: '\n \n Weekly bread sales \n '},
+};
+
+export function AltGuide() {
+ const [path, setPath] = useState([]);
+ const current = path.length === 0 ? "start" : path[path.length - 1].next;
+ const result = guideResults[current];
+ const node = guideNodes[current];
+ const answer = (choice) => setPath(path.concat({from: current, choice, next: node[choice]}));
+ return (
+
+ {path.length > 0 ? (
+
+ {path.map((step, index) => ({guideNodes[step.from].question} {step.choice === "yes" ? "Yes" : "No"} ))}
+
+ ) : null}
+
+ {node ? (
+
+
{node.question}
+
answer("yes")}>Yes {" "}
+
answer("no")}>No
+
+ ) : (
+
+
Result: {result.title}
+
{result.advice}
+
{result.code}
+
+ )}
+
+
+ setPath(path.slice(0, -1))} disabled={path.length === 0}>Back {" "}
+ setPath([])} disabled={path.length === 0}>Start again
+
+
+ );
+}
+
+export const scenarios = [
+ {label: "A photo in a recipe", kind: "informative", icon: "🥖", sees: "A photo of a round loaf with a golden, crackly crust, in an article about sourdough.", context: "Our sourdough has a crackly, deep golden crust and a soft, airy inside.", sample: "A round sourdough loaf with a deep golden, crackly crust, scored with one curved cut."},
+ {label: "A swirl divider", kind: "decorative", icon: "〰️", sees: "A thin wavy line that separates two sections. It adds no information.", context: "(Between two sections of text.)", sample: ""},
+ {label: "A logo that links home", kind: "functional", role: "link", icon: "🥖", sees: "The Baker's Corner logo in the header. Clicking it goes to the home page.", context: "(Inside a link to the home page.)", sample: "Baker's Corner, home"},
+ {label: "A sign with words", kind: "text", icon: "🪧", sees: "A hand-painted sign that says: Closed on Mondays.", context: "(An image of a sign. No other text on the page says the same.)", text: "Closed on Mondays", sample: "Closed on Mondays"},
+ {label: "A sales chart", kind: "complex", icon: "📊", sees: "A bar chart. Weekly sales climb from 120 loaves in March to 310 in June.", context: "The full numbers are in the table under the chart.", sample: "Bar chart: weekly bread sales rose from 120 loaves in March to 310 in June. Full data in the table below."},
+ {label: "A print button icon", kind: "functional", role: "button", icon: "🖨️", sees: "A printer icon on a button, with no visible text.", context: "(The only content inside the button.)", sample: "Print recipe"},
+];
+
+export function evaluateAlt(scn, alt) {
+ const text = alt.trim();
+ const notes = [];
+ let level = "ok";
+ const fail = (note) => { level = "fail"; notes.push(note); };
+ const warn = (note) => { if (level === "ok") level = "warn"; notes.push(note); };
+ if (scn.kind === "decorative") {
+ if (text !== "") fail("This image is decorative. Use an empty alt (alt=\"\"), so screen readers skip it.");
+ return {level, notes};
+ }
+ if (text === "") {
+ fail("An empty alt hides this image, and it matters here.");
+ return {level, notes};
+ }
+ if (/^(image|picture|photo|graphic|icon)\b/i.test(text)) warn("Do not start with image, picture, or photo. Screen readers already say it is an image.");
+ if (/\.(jpe?g|png|gif|svg|webp)$/i.test(text) || /^(img|dsc|image)[-_ ]?\d+/i.test(text)) fail("This looks like a file name. It tells nobody anything.");
+ if (text.length > 125) warn("This is long (" + text.length + " characters). Keep alt short, and put extra detail in the page.");
+ if (scn.kind === "functional" && /\b(icon|button|link|logo)\b/i.test(text) && !/home/i.test(text)) warn("Describe the action or destination, not the icon. The role is announced for you.");
+ if (scn.kind === "text" && text.toLowerCase() !== scn.text.toLowerCase()) warn("For an image of text, repeat the words exactly.");
+ if (scn.kind === "informative" && text.split(/\s+/).length < 3) warn("Very short. Say what matters about this image on this page.");
+ if (scn.kind === "complex" && !/(below|table|details|following|data)/i.test(text)) warn("Mention where the full detail is, such as the table below.");
+ if (notes.length === 0) notes.push("This looks good. Read it aloud, and ask: does it do the job of the image?");
+ return {level, notes};
+}
+
+export function AltWriter() {
+ const [index, setIndex] = useState(0);
+ const [alt, setAlt] = useState("");
+ const [show, setShow] = useState(false);
+ const scn = scenarios[index];
+ const result = evaluateAlt(scn, alt);
+ const spoken = scn.kind === "decorative" && alt.trim() === "" ? "(nothing: the image is skipped)" : alt.trim() === "" ? "(unlabelled " + (scn.role || "image") + ")" : (scn.role || "image") + ", \"" + alt.trim() + "\"";
+ const label = result.level === "ok" ? "Good" : result.level === "warn" ? "Could be better" : "Needs work";
+ return (
+
+
+ Pick an image to describe
+ { setIndex(Number(event.target.value)); setAlt(""); setShow(false); }}>
+ {scenarios.map((item, i) => ({item.label} ))}
+
+
+
+
{scn.icon}
+
+
What a sighted visitor sees: {scn.sees}
+
Context: {scn.context}
+
+
+
+ Your alt text (leave it empty if you think the image is decorative)
+ setAlt(event.target.value)} style={{width: "100%"}} />
+
+
A screen reader may say: {spoken}
+
Feedback: {label}.
+
+ {result.notes.map((note, i) => ({note} ))}
+
+
setShow(!show)}>{show ? "Hide the sample answer" : "Show a sample answer"}
+ {show ?
Sample: {scn.sample === "" ? 'alt=""' : 'alt="' + scn.sample + '"'}
: null}
+
+ These checks are simple rules of thumb, not a judge. Good alt text always depends on the context and the purpose of the image, and a rule cannot see either. Different screen readers also announce images differently, so the spoken line is only an example.
+
+
+ );
+}
+
+export const checkSamples = {
+ messy: ' \n \n \n \n \n\n \n Dark rye bread \n \n \n ',
+ fixed: ' \n \n \n\n \n Dark rye bread \n \n \n ',
+};
+
+export function checkImage(img) {
+ const issues = [];
+ const src = (img.getAttribute("src") || "").split("/").pop();
+ const hasAlt = img.hasAttribute("alt");
+ const alt = (img.getAttribute("alt") || "").trim();
+ if (!hasAlt) return {src, alt: null, kind: "fail", issues: ["Missing alt attribute. Some screen readers read the file name."]};
+ if (alt === "") {
+ const holder = img.closest("a, button");
+ if (holder) {
+ const other = (holder.textContent || "").replace(/\s+/g, "").length;
+ if (other === 0 && !holder.getAttribute("aria-label")) return {src, alt, kind: "fail", issues: ["Empty alt inside a link or button with no other text, so it has no name."]};
+ }
+ return {src, alt, kind: "decorative", issues: ["Decorative (empty alt). Check that it really adds no information."]};
+ }
+ if (/^(image|picture|photo|graphic|icon)\b/i.test(alt)) issues.push("Starts with image, photo, or picture. That is announced already.");
+ if (/\.(jpe?g|png|gif|svg|webp)$/i.test(alt) || /^(img|dsc|image)[-_ ]?\d+/i.test(alt)) issues.push("Looks like a file name.");
+ if (/^(image|photo|picture|logo|icon|banner|graphic)$/i.test(alt)) issues.push("Too vague. Say what the image shows or does.");
+ if (alt.length > 125) issues.push("Long (" + alt.length + " characters). Consider a short alt plus a longer description nearby.");
+ const figure = img.closest("figure");
+ const caption = figure ? figure.querySelector("figcaption") : null;
+ if (caption && caption.textContent.trim().toLowerCase() === alt.toLowerCase()) issues.push("Repeats the figure caption word for word. Say something new, or use an empty alt.");
+ return {src, alt, kind: issues.length > 0 ? "warn" : "ok", issues};
+}
+
+export function AltCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(checkSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const images = Array.from(doc.querySelectorAll("img"));
+ const rows = images.map(checkImage);
+ const bad = rows.filter((row) => row.kind === "fail" || row.kind === "warn").length;
+ const choose = (value) => { setWhich(value); setHtml(checkSamples[value]); };
+ return (
+
+
+ Which page do you want to check?
+ choose("messy")} /> A messy page
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} images found, {bad} to look at.
+
+ {rows.map((row, index) => (
+
+ {row.src || "(no src)"}: alt {row.alt === null ? missing : row.alt === "" ? "" : {'"' + (row.alt.length > 60 ? row.alt.slice(0, 60) + "..." : row.alt) + '"'}}
+ {" "}{row.kind === "ok" ? "OK" : row.kind === "decorative" ? "Decorative" : row.kind === "warn" ? "Check" : "Problem"}
+ {row.issues.length > 0 ? : {row.issues.join(" ")} : null}
+
+ ))}
+
+
+ A tool can find missing and suspicious alt text, but it cannot tell you whether the description is right. Only a person who knows the image and the page can do that. The images are not loaded here, only their HTML is checked.
+
+
+ );
+}
+
+export function AltChecker() {
+ return {() => } ;
+}
+
+Alt text is the short description you give an image, so that people who **cannot see it** still get what it offers. It is one of the oldest accessibility features of HTML, and one of the most often done badly: missing, stuffed with keywords, or just "image.jpg."
+
+Writing good alt text is less about **rules** and more about one question: **if this image could not be seen, what would the person need to be told?** The answer changes with the image and with the page. In this lesson you will learn to tell the main kinds of images apart, and to write alt text that helps.
+
+
+
+
+## What You Will Learn
+
+- **Who** relies on alt text, and why
+- The **five kinds** of images, and how to tell them apart
+- How to write **short, useful** descriptions
+- When to use an **empty alt** (`alt=""`)
+- How to handle **images in links and buttons,** images of text, and **complex images**
+- Alt text for **SVG, figures,** and other special cases
+- How to **check** your images
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Weekly bread sales
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Use the guide with an image from your own project. The answers lead you to the kind of image, and then to the right way to write the alt.
+
+## Who Needs Alt Text?
+
+- **People who are blind** and use a screen reader, which reads alt text aloud in place of the image.
+- **People with low vision** who use screen readers or magnification together with their remaining sight.
+- **People on slow or expensive connections,** or with images turned off, who see the alt text where the image should be.
+- **Anyone when an image fails to load** (a broken link, a blocked domain, a server error).
+- **People using text-only browsers,** reading tools, and voice assistants.
+- **Search engines,** which use alt text to understand what an image shows. This is a bonus, never the goal.
+
+It is also a legal and standards requirement in many places: WCAG success criterion **1.1.1 (Non-text Content)** asks for a text alternative for every image that carries meaning.
+
+## The `alt` Attribute
+
+The `alt` attribute goes on every `img` element:
+
+```html
+
+```
+
+Three states exist, and they mean different things:
+
+| Code | Meaning |
+| :-- | :-- |
+| `alt="A round loaf"` | This image has a description. |
+| `alt=""` | This image is **decorative.** Skip it. |
+| no `alt` at all | **A mistake.** The tool does not know what to do, and some screen readers read out the file name. |
+
+The `title` attribute is **not** a replacement for `alt`. It shows as a tooltip for mouse users, and is not reliably announced.
+
+## The Five Kinds of Images
+
+The right alt text depends on the **purpose** of the image on **this page.**
+
+| Kind | What it does | What to write |
+| :-- | :-- | :-- |
+| **Informative** | Adds information or meaning | A short description of what matters |
+| **Decorative** | Only decorates, or repeats nearby text | An **empty** alt: `alt=""` |
+| **Functional** | Is inside a link or button | Where it goes, or what it does |
+| **Image of text** | Shows words as a picture | The **same words** |
+| **Complex** | A chart, graph, diagram, or map | A short alt, plus a **full description** in the page |
+
+The same picture can be different kinds in different places. A photo of a loaf is **informative** in a recipe, **decorative** next to a caption that says everything, and **functional** if it is a link to the recipe.
+
+## Writing Good Alt Text
+
+
+
+
+```html title="index.html"
+
+
+
+
+
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Try each of the six scenarios in the lab. Write your own alt first, read the feedback, and only then look at the sample.
+
+Here are the habits behind the feedback:
+
+1. **Describe the purpose, in context.** Ask what the image is **for** on this page, not just what it shows.
+2. **Be concise.** One short sentence is usually enough. Many guides suggest staying under about 125 characters. That is a guideline, not a rule, so use a longer description in the page for anything more.
+3. **Skip "image of," "photo of," and "picture of."** The screen reader already says "image."
+4. **Do not repeat nearby text.** If the caption says "Sourdough loaf," you do not need the same words again. Say something new, or use `alt=""`.
+5. **Write like a person.** Natural language, with normal punctuation, reads better when spoken aloud.
+6. **Never stuff keywords.** Alt text is for people first. Search engines punish stuffing, and screen reader users have to listen to it.
+7. **Do not guess.** If you do not know what an image shows, find out, or do not use it.
+
+:::tip
+A good test: **read the page aloud, skipping the images, and say the alt text instead.** Does it still make sense? Does it give the same information a sighted person gets?
+:::
+
+## Decorative Images
+
+If an image adds **nothing** a person needs, hide it from assistive technology with an **empty** alt:
+
+```html
+
+```
+
+Better still, use **CSS** for purely decorative pictures, so they never appear in the HTML at all:
+
+```css
+.hero {
+ background-image: url("flour.jpg");
+}
+```
+
+Images added with CSS `background-image` are **not announced,** so they should never carry meaning. If the image is important, use an `img`.
+
+Be careful: **empty alt is a decision, not a shortcut.** Using it to skip work on images that do matter hides content from some people.
+
+## Images in Links and Buttons
+
+When an image is the **only** content of a link or button, its alt text is the link's **name** (see [Accessible Names](./accessible-names)). Write where the link **goes,** or what the button **does:**
+
+```html
+
+
+
+
+
+
+
+```
+
+If the link also has **text** beside the image, the image is usually redundant, so use `alt=""` to avoid saying it twice.
+
+## Images of Text
+
+If an image shows words that matter, the alt text should **repeat them exactly.**
+
+Even better, **avoid images of text.** WCAG (criterion 1.4.5) prefers real text styled with CSS. Real text can be resized without blur, translated, searched, copied, and changed to high-contrast colours. A logo is the main fair exception.
+
+## Complex Images: Charts, Diagrams, and Maps
+
+A chart can hold hundreds of numbers, and no short alt can say them all. Use **two layers:**
+
+1. **A short alt text** that says what the image is and its main point.
+2. **The full information** in the page itself, so everyone can use it. A **table** next to a chart is ideal, because sighted users benefit from it too.
+
+```html
+
+
+ Weekly bread sales, March to June
+
+
+
+ Data for this chart
+
+ Weekly bread sales
+ Month Loaves
+ March 120
+ June 310
+
+
+```
+
+You can also link to a separate page with the full description, or point to it with `aria-describedby` (covered later in this section).
+
+## Figures and Captions
+
+`figure` and `figcaption` give an image a **visible** caption. The caption is **not** the alt text, and does not replace it.
+
+- If the caption **already describes** the image, use `alt=""`, or add only what the caption lacks.
+- If the caption gives **context** ("Our founder in 1998"), the alt text should describe what you **see.**
+
+## SVG, Icons, and Other Images
+
+- **Inline SVG that carries meaning:** give it `role="img"` and a name, either with `aria-label` or a `title` element:
+
+```html
+
+ Print recipe
+
+
+```
+
+- **Decorative SVG:** use `aria-hidden="true"` so it is skipped.
+- **Icon fonts and emoji:** screen readers may read them as odd names or characters. Hide purely decorative ones with `aria-hidden="true"`, and give useful ones a text alternative.
+- **`picture` with several sources:** put the `alt` on the **`img` inside** it, not on `source`.
+- **Image maps:** every `area` needs its own `alt`.
+- **Video and audio** need captions and transcripts, not alt text. See the earlier section on audio and video.
+
+## Check Your Images
+
+The checker below looks at every `img` in a snippet of HTML, and flags missing alt, file-name alt, "image of" starts, very long alt, links with no name, and captions that repeat the alt.
+
+
+
+
+```html title="index.html"
+
+
+
+
+
+
+ Dark rye bread
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Run the messy page first, then fix each problem. In real life, the browser's **Accessibility panel** shows the name of each image, and automated tools (such as Lighthouse or axe) find the missing ones. But **only a person** can judge whether the text is useful.
+
+## Good Habits
+
+1. **Ask what the image is for,** before you write.
+2. **Use `alt` on every `img`.** Empty for decoration, never missing.
+3. **Keep it short and natural.**
+4. **Describe the destination** for images in links and buttons.
+5. **Repeat words** shown in an image of text, or use real text instead.
+6. **Put complex data in the page,** not in the alt.
+7. **Give people who upload images a way to add alt text,** and explain why.
+8. **Review the alt text** when a design changes. The same image may now play another role.
+9. **Test with a screen reader** on your most important pages.
+
+## Accessibility Checklist
+
+- [ ] Every `img` has an `alt` attribute.
+- [ ] Informative images have short, meaningful descriptions.
+- [ ] Decorative images use `alt=""` (or are CSS backgrounds).
+- [ ] Images in links and buttons describe the destination or action.
+- [ ] Images of text repeat the text, or have been replaced by real text.
+- [ ] Charts and diagrams have a short alt and a full description in the page.
+- [ ] Alt text does not start with "image of," and is not a file name or a list of keywords.
+- [ ] Captions and alt text do not repeat each other.
+- [ ] Meaningful SVG has `role="img"` and a name, and decorative SVG is hidden.
+- [ ] Alt text is translated along with the rest of the page.
+
+## Security and Privacy Notes
+
+- **Do not identify people in alt text** unless they have agreed and it is needed. Naming someone in a public description can reveal who they are to search engines and scrapers.
+- **Do not put private information in alt text** (home addresses, ID numbers, medical details). Alt text is public, and gets indexed.
+- **Be careful with automatic alt text.** Services that generate descriptions with artificial intelligence may send your images to an outside server, which is a privacy issue for personal or sensitive pictures. They can also be wrong or biased. Review every description before you publish it.
+- **User uploads:** photos often carry hidden metadata, such as location. This is separate from alt text, but if your site accepts uploads, strip that data, and let people write their own alt text.
+- **Do not describe an image differently from what it shows.** Misleading alt text (to fool search engines or readers) is both deceptive and an accessibility failure.
+
+## Global Audience Notes
+
+- **Translate alt text** with the rest of your content. Make sure your translation workflow includes attributes, not only visible text.
+- **Set the page language** with `lang`, so the screen reader voice pronounces the description correctly.
+- **Avoid images of text.** They cannot be translated, and text in a picture is blurry on small screens and breaks with large text sizes.
+- **Avoid culture-specific references** in descriptions. A joke, an idiom, or a local landmark may mean nothing to a reader in another country.
+- **Use simple, clear language.** It translates better, and it is easier for people reading in a second language.
+- **Slow networks:** on a slow connection, alt text is what people read **while** the image loads, or in place of it. Make it useful on its own.
+- **Right-to-left languages:** write the description in the direction of the language, and check that any numbers or Latin words inside it display in the right order.
+
+## Common Mistakes to Avoid
+
+1. **No `alt` attribute at all.**
+2. **File names as alt text** ("IMG_4021.jpg").
+3. **Starting with "image of" or "photo of."**
+4. **Keyword stuffing for search engines.**
+5. **The same alt text on every image.**
+6. **Describing the logo instead of the link destination.**
+7. **An empty alt on an image inside a link with no other text.**
+8. **Repeating the caption word for word.**
+9. **Putting a whole chart's data in one very long alt.**
+10. **Using `title` instead of `alt`.**
+11. **Using images of text, with no text alternative.**
+12. **Publishing automatic alt text without reading it.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the decision guide on five images from a site you like, and write down the kind of each.
+2. In the writing lab, write your own alt for each scenario before looking at the samples.
+3. Run the checker on the messy page, and fix every item.
+4. Take a page you built, and list every `img`. Is any missing an `alt`?
+5. Turn off images in your browser (or use a text-only view), and see which pages still make sense.
+6. Replace an image of text on one of your pages with real text styled in CSS.
+7. Add a data table beneath a chart image, and shorten the alt to match.
+8. Make a logo link with the right alt, and test it with a screen reader.
+9. Add `role="img"` and a `title` to an inline SVG, and look at its name in the Accessibility panel.
+10. Bonus: pick a news photo, and write three alt texts for three different pages (a news article, a gallery, a link). How do they differ?
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What is the difference between `alt=""` and no `alt` at all?
+
+An empty alt tells assistive technology the image is decorative, so it is skipped. A missing alt is a mistake, and some screen readers read the file name instead.
+
+
+
+
+2. Name the five kinds of images.
+
+Informative, decorative, functional (in a link or button), image of text, and complex (charts, diagrams, maps).
+
+
+
+
+3. What should the alt text of a logo link say?
+
+Where the link goes, such as "Baker's Corner, home," not a description of the logo's picture.
+
+
+
+
+4. Why not start alt text with "image of"?
+
+Screen readers already announce that it is an image, so the words are repeated for nothing.
+
+
+
+
+5. How do you handle a complex chart?
+
+Write a short alt that says what it is and its main point, and put the full data in the page, for example in a table.
+
+
+
+
+6. Should the alt text repeat the figure caption?
+
+No. If the caption already says it, use an empty alt, or write something that adds to it.
+
+
+
+## Key Takeaways
+
+- **Every `img` needs an `alt` attribute.** What you put in it depends on the **purpose** of the image on **that page.**
+- **Informative** images get a short description. **Decorative** images get `alt=""`. **Functional** images describe the **action.** **Images of text** repeat the words.
+- **Complex** images need a short alt **and** a full description in the page.
+- **Be short, natural, and honest.** No "image of," no file names, no keyword stuffing.
+- **Prefer real text** to images of text, and **CSS** to decorative images.
+- **Translate and review** alt text, including any generated automatically.
+- Tools can find missing alt text, but only **a person** can judge whether it helps.
+
+Next, learn how to make links clear and usable in [Accessible Links](./accessible-links).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/aria-describedby.mdx b/tutorials/html/13-accessibility/aria-describedby.mdx
index e69de29bb..422bbe698 100644
--- a/tutorials/html/13-accessibility/aria-describedby.mdx
+++ b/tutorials/html/13-accessibility/aria-describedby.mdx
@@ -0,0 +1,655 @@
+---
+id: html-aria-describedby
+title: "Aria Describedby"
+sidebar_label: "Aria Describedby"
+sidebar_position: 19
+slug: /html/accessibility/aria-describedby
+description: "Learn how aria-describedby adds extra help to a control: form hints and errors, warnings on buttons, dialog text, and accessible tooltips. Understand the difference between a name and a description, why descriptions can be missed, and how to keep them accurate. Includes a description builder lab, a tooltip lab for WCAG 1.4.13, and a live aria-describedby checker."
+keywords:
+ - aria-describedby
+ - accessible description
+ - form hints
+ - error messages
+ - accessible tooltip
+ - WCAG 1.4.13
+ - aria-details
+ - name vs description
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, aria, aria-describedby, forms, tooltips, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const descScenarios = [
+ {
+ id: "password",
+ title: "A password field",
+ role: "password edit text",
+ name: "Password",
+ defaults: ["rule"],
+ pieces: [
+ {key: "rule", text: "At least 12 characters."},
+ {key: "tip", text: "A phrase of four or more words works well."},
+ {key: "err", text: "Error: This password is too short."},
+ ],
+ },
+ {
+ id: "delete",
+ title: "A delete button",
+ role: "button",
+ name: "Delete recipe",
+ defaults: ["warn"],
+ pieces: [
+ {key: "warn", text: "This cannot be undone."},
+ {key: "extra", text: "A copy stays in the trash for 30 days."},
+ ],
+ },
+ {
+ id: "dialog",
+ title: "A dialog",
+ role: "dialog",
+ name: "Delete recipe?",
+ defaults: ["body"],
+ pieces: [
+ {key: "body", text: "This will remove Rye bread for everyone on your team."},
+ ],
+ },
+];
+
+export function DescriptionBuilderLab() {
+ const [index, setIndex] = useState(0);
+ const [ids, setIds] = useState(descScenarios[0].defaults);
+ const [shown, setShown] = useState(true);
+ const [descOff, setDescOff] = useState(false);
+ const scn = descScenarios[index];
+ const full = (key) => "desc-" + scn.id + "-" + key;
+ const attr = ids.length > 0 ? ids.map(full).join(" ") : undefined;
+ const description = ids.map((key) => scn.pieces.find((piece) => piece.key === key)).filter(Boolean).map((piece) => piece.text).join(" ");
+ const choose = (next) => {
+ setIndex(next);
+ setIds(descScenarios[next].defaults);
+ };
+ const toggle = (key) => setIds(ids.indexOf(key) === -1 ? ids.concat(key) : ids.filter((item) => item !== key));
+ const piece = (key) => scn.pieces.find((item) => item.key === key);
+ const pieceStyle = (key) => ({margin: "0.2rem 0", display: shown ? "block" : "none"});
+ const spoken = scn.name + ", " + scn.role + (description && !descOff ? ". " + description : "");
+ const notes = [];
+ if (ids.length === 0) notes.push("No ids are listed, so nothing is described. The control is named, and that is all.");
+ if (descOff && description) notes.push("This screen reader user has descriptions turned off, so none of this help is read. Never put essential information only in a description.");
+ if (!shown && description) notes.push("The help text is hidden on screen. A screen reader still reads it, but sighted people cannot see it. Keep help visible for everyone.");
+ if (ids.indexOf("err") !== -1) notes.push("An error should be linked only while it is showing. Add its id when the error appears, and remove it when the error is fixed.");
+ if (notes.length === 0) notes.push("This works: the name says what it is, and the description adds the extra help.");
+ return (
+
+
+ Pick an example
+ choose(Number(event.target.value))}>
+ {descScenarios.map((item, i) => ({item.title} ))}
+
+
+
+ Which text should describe it? (It is read in the order you tick.)
+ {scn.pieces.map((item) => (
+
+ toggle(item.key)} /> {item.text}
+
+ ))}
+ setShown(event.target.checked)} /> Show the help text on the page
+ setDescOff(event.target.checked)} /> The listener has screen reader descriptions turned off
+
+
+
The element:
+ {scn.id === "password" ? (
+
+
Password
+
+ {scn.pieces.map((item) => (
{item.text}
))}
+
+ ) : null}
+ {scn.id === "delete" ? (
+
+
Delete recipe
+ {scn.pieces.map((item) => (
{item.text}
))}
+
+ ) : null}
+ {scn.id === "dialog" ? (
+
+
Delete recipe?
+ {scn.pieces.map((item) => (
{item.text}
))}
+
Delete {" "}
+
Cancel
+
+ ) : null}
+
The attribute: {attr ? 'aria-describedby="' + attr + '"' : "(none)"}
+
A screen reader may say: {spoken}
+
+ {notes.map((note, i) => ({note} ))}
+
+
+
+ Notice the order: first the name, then the role, then the description. The spoken line is a simplified example. Different screen readers word it differently, may pause before the description, and many let people turn descriptions off.
+
+
+ );
+}
+
+export function TooltipLab() {
+ const [showOnFocus, setShowOnFocus] = useState(true);
+ const [escapeCloses, setEscapeCloses] = useState(true);
+ const [hoverable, setHoverable] = useState(true);
+ const [hover, setHover] = useState(false);
+ const [focus, setFocus] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const visible = !dismissed && (hover || (showOnFocus && focus));
+ const enter = () => setHover(true);
+ const leave = () => {
+ setHover(false);
+ if (!focus) setDismissed(false);
+ };
+ const blur = () => {
+ setFocus(false);
+ if (!hover) setDismissed(false);
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape" && escapeCloses) setDismissed(true);
+ };
+ const wrapperHandlers = hoverable ? {onMouseEnter: enter, onMouseLeave: leave} : {};
+ const buttonHandlers = hoverable ? {} : {onMouseEnter: enter, onMouseLeave: leave};
+ const verdict = (ok) => (ok ? "Yes" : "No");
+ return (
+
+
+ Build the tooltip
+ setShowOnFocus(event.target.checked)} /> Also show it when the button has keyboard focus
+ setEscapeCloses(event.target.checked)} /> Esc closes it
+ setHoverable(event.target.checked)} /> It stays open while the pointer is over the tooltip
+
+
+
+ setFocus(true)} onBlur={blur} onKeyDown={onKey} {...buttonHandlers}>Help
+
+ Opens the help centre in a new tab.
+
+
+
+
Does it meet the three rules of WCAG 1.4.13?
+
+ Dismissible (a way to close it without moving the pointer or focus): {verdict(escapeCloses)}
+ Hoverable (the pointer can move onto the tooltip without it vanishing): {verdict(hoverable)}
+ Persistent (it stays until you move away, press Esc, or the information is no longer valid): Yes
+
+
Can a keyboard user see it? {verdict(showOnFocus)}
+
+ Move the pointer over the button, then Tab to it, then press Esc. The tooltip is in the page all the time, and aria-describedby points to it, so a screen reader reads it as the description whether or not it is showing. Visibility is for sighted people, and the rules above make sure it works for keyboards and for people who need more time. (The three rules are described briefly here. Read the full text of the criterion before you rely on this.)
+
+
+ );
+}
+
+export const describedbySamples = {
+ messy: 'Password \n \nAt least 12 characters.
\nEmail \n \nEnter a valid email.
\nDelete \n
\nSave \n\nName \n \nName \nOrphan tip
',
+ fixed: 'Password \n \nAt least 12 characters.
\nEmail \n \nError: Enter an email address that includes an @ sign.
\nDelete recipe \nThis cannot be undone.
\nHelp \nOpens the help centre.
',
+};
+
+export function dbText(node) {
+ if (node.nodeType === 3) return node.textContent;
+ if (node.nodeType !== 1) return "";
+ if (node.tagName.toLowerCase() === "img") return node.getAttribute("alt") || "";
+ let out = "";
+ node.childNodes.forEach((child) => { out += " " + dbText(child) + " "; });
+ return out;
+}
+
+export function dbNameOf(el, doc) {
+ const clean = (value) => (value || "").replace(/\s+/g, " ").trim();
+ const ids = clean(el.getAttribute("aria-labelledby"));
+ if (ids) return clean(ids.split(" ").map((id) => { const target = doc.getElementById(id); return target ? target.textContent : ""; }).join(" "));
+ if (clean(el.getAttribute("aria-label"))) return clean(el.getAttribute("aria-label"));
+ const id = el.getAttribute("id");
+ if (id) {
+ const found = Array.from(doc.querySelectorAll("label")).find((item) => item.getAttribute("for") === id);
+ if (found) return clean(found.textContent);
+ }
+ const tag = el.tagName.toLowerCase();
+ if (tag === "button" || tag === "a") return clean(dbText(el));
+ return clean(el.getAttribute("title")) || clean(el.getAttribute("placeholder"));
+}
+
+export function DescribedbyCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(describedbySamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const idCounts = {};
+ Array.from(doc.querySelectorAll("[id]")).forEach((el) => { idCounts[el.id] = (idCounts[el.id] || 0) + 1; });
+ const referenced = {};
+ Array.from(doc.querySelectorAll("[aria-describedby]")).forEach((el) => { el.getAttribute("aria-describedby").split(/\s+/).filter(Boolean).forEach((id) => { referenced[id] = true; }); });
+ const rows = [];
+ Array.from(doc.querySelectorAll("[aria-describedby], [aria-invalid='true'], input[placeholder]")).forEach((el) => {
+ const tag = el.tagName.toLowerCase();
+ const raw = (el.getAttribute("aria-describedby") || "").trim();
+ const ids = raw.split(/\s+/).filter(Boolean);
+ const issues = [];
+ const notes = [];
+ const name = dbNameOf(el, doc);
+ const parts = [];
+ if (el.hasAttribute("aria-describedby") && ids.length === 0) issues.push("An empty aria-describedby does nothing.");
+ ids.forEach((id) => {
+ const target = doc.getElementById(id);
+ if (!target) {
+ issues.push("The id \"" + id + "\" does not exist, so it is ignored.");
+ return;
+ }
+ if (idCounts[id] > 1) issues.push("The id \"" + id + "\" is used " + idCounts[id] + " times. Only the first is used.");
+ const text = dbText(target).replace(/\s+/g, " ").trim();
+ if (text === "") issues.push("The element with the id \"" + id + "\" is empty, so there is nothing to read. Fill it, or remove the reference.");
+ else parts.push(text);
+ if (target.querySelector("a[href], button, input, select, textarea")) notes.push("The element \"" + id + "\" contains a link or control. Inside a description it is read as plain text, and cannot be used.");
+ if (name && text && text.toLowerCase() === name.toLowerCase()) notes.push("The description \"" + text + "\" repeats the name. Remove it.");
+ if (text.length > 200) notes.push("The description for \"" + id + "\" is long (" + text.length + " characters). Keep it short.");
+ });
+ if (el.getAttribute("aria-invalid") === "true" && ids.length === 0) issues.push("Marked invalid, but no error message is connected with aria-describedby.");
+ if (tag === "input" && el.hasAttribute("placeholder") && !el.hasAttribute("aria-describedby") && el.getAttribute("aria-invalid") !== "true") notes.push("A placeholder is not a hint. If people need help, put it in text, and connect it with aria-describedby.");
+ if (!el.hasAttribute("aria-describedby") && el.getAttribute("aria-invalid") !== "true" && notes.length === 0) return;
+ rows.push({tag, name, raw, desc: parts.join(" "), issues, notes});
+ });
+ Array.from(doc.querySelectorAll('[role="tooltip"]')).forEach((el) => {
+ if (!el.id || !referenced[el.id]) rows.push({tag: "div role=tooltip", name: "", raw: el.id || "", desc: dbText(el).replace(/\s+/g, " ").trim(), issues: ["A tooltip that nothing points to with aria-describedby. Screen readers will not connect it with any control."], notes: []});
+ });
+ const bad = rows.reduce((sum, row) => sum + row.issues.length, 0);
+ const choose = (value) => { setWhich(value); setHtml(describedbySamples[value]); };
+ return (
+
+
+ Which HTML do you want to check?
+ choose("messy")} /> Messy descriptions
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} items checked, {bad} problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + ">"} {row.name ? named {'"' + row.name + '"'} : null}{row.desc ? , described {'"' + (row.desc.length > 60 ? row.desc.slice(0, 60) + "..." : row.desc) + '"'} : null}{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+
+ This reads the HTML only. It cannot tell whether a description is helpful or accurate, or how a particular screen reader will announce it. Test the real page.
+
+
+ );
+}
+
+export function DescribedbyChecker() {
+ return {() => } ;
+}
+
+A name tells you **what something is.** Sometimes that is not enough. A password field is "Password," but you also need to know the **rules.** A button is "Delete recipe," but you also need to know it **cannot be undone.** A field has an error, and you need to know **what to fix.**
+
+That extra help is a **description,** and `aria-describedby` is how you attach it. Like `aria-labelledby`, it **points to text that is already on the page.** The difference is what the text is for: not to name the control, but to **give it more help.**
+
+
+
+
+## What You Will Learn
+
+- The difference between a **name** and a **description**
+- How **`aria-describedby`** works
+- How to use it for **form hints, errors, warnings,** and **dialog text**
+- How to build an **accessible tooltip** (WCAG 1.4.13)
+- Why a description can be **missed,** and what to do about it
+- How to keep descriptions **accurate** when the page changes
+- How to **check** your descriptions
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+Password
+
+At least 12 characters.
+
+
+Email address
+
+We only use it to send your receipt.
+Error: Enter an email address that includes an @ sign.
+
+
+Delete recipe
+This cannot be undone.
+
+
+
+
Delete recipe?
+
This will remove Rye bread for everyone on your team.
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Pick each example in the lab. Change which help text is included, and turn off descriptions to hear what happens to people who do not receive them.
+
+## Name or Description?
+
+| | **Name** | **Description** |
+| :-- | :-- | :-- |
+| **Answers** | What is it? | What else should I know? |
+| **Length** | Short: a word or a few | A sentence or two |
+| **Attribute** | `aria-label`, `aria-labelledby`, `label` | `aria-describedby` |
+| **If it is missing** | The control is unlabelled, which is a serious failure | The control still works, but with less help |
+| **Example** | "Password" | "At least 12 characters." |
+| **Heard** | First, with the role | After the name, role, and state |
+
+A good test: **if the text identifies the control, it is a name. If it helps you use the control, it is a description.**
+
+## How It Works
+
+The value of `aria-describedby` is a list of **ids,** separated by spaces, just like `aria-labelledby`. The browser finds each element, takes its text, joins the pieces in the order listed, and passes the result to assistive technology as the **accessible description.**
+
+- **It is read after the name, role, and state.** A screen reader typically says: "Password, edit text, protected. At least 12 characters."
+- **Hidden text counts,** just as it does for names. A referenced element that is hidden is still used. (But see below: hidden help is no help to sighted people.)
+- **Missing ids are ignored,** silently.
+- **The order matters.** List the hint before the error, or the other way round, to suit how you want it read.
+- **Formatting is lost.** A description is read as plain text. **Links, buttons, lists, and bold** inside it are flattened, and cannot be used. Keep descriptions simple.
+- **It is separate from the name.** `aria-describedby` does not change the name. Do not use it to repair a missing name.
+- **Many elements can share one description.** Several fields can point to the same note ("All fields are required").
+
+## Common Uses
+
+### Form hints
+
+Put formats, rules, and examples in a hint, and connect it. Show it **above or beside the field,** so everyone sees it before typing. See [Accessible Forms](./accessible-forms).
+
+### Error messages
+
+Add the error to the field's `aria-describedby`, and set `aria-invalid="true"`. The next time the field is focused, the error is read as part of the description.
+
+### Warnings and consequences
+
+A button that deletes, pays, or sends should say so: "This cannot be undone," "You will be charged 20 dollars."
+
+### Dialog text
+
+A dialog is named by its heading (`aria-labelledby`), and described by its body text (`aria-describedby`). That way, when the dialog opens, the screen reader announces both. For urgent confirmations, use `role="alertdialog"` with the same pair. See [ARIA Roles](./aria-roles).
+
+### Tooltips
+
+A tooltip is a **description,** not a name. Point to it from the control. More on this below.
+
+### Data and complex content
+
+A table or a chart can point to a paragraph that explains it: `aria-describedby="chart-summary"`. For a long, structured description (with headings, lists, and links), look at `aria-details`, which points to **real, navigable content.** Its support in screen readers is more limited, so do not depend on it alone, and always keep the information in the visible page.
+
+## Descriptions Can Be Missed
+
+This is the most important thing to know about `aria-describedby`:
+
+- **Many screen reader users turn descriptions off** or reduce them, because they can be long and repeated.
+- **Descriptions are not read** when you move through a list of links, buttons, or form fields, or when you read a page line by line without focusing a control.
+- **Descriptions can be read late,** after a short pause, and may be cut off when the user presses a key.
+- **Changes may not be re-read.** If the description changes while the control is focused, a screen reader may not announce it again.
+
+So:
+
+1. **Never put essential information only in a description.** If something is required, say so in the label or the visible text ("Password (required)"). Use the description for **extras.**
+2. **Always show the same help on screen.** A description should connect text that **everyone can see,** not hide text that only some people hear.
+3. **For urgent news,** such as an error that appears while someone is typing, use a **live region** (see [ARIA Live Regions](./aria-live)) as well, or move focus, as shown in [Focus Management](./focus-management).
+
+## Keeping Descriptions Up to Date
+
+Descriptions that appear and disappear (errors, character counts) need care:
+
+- **Add the error's id** to `aria-describedby` when the error appears, and **remove it** when it is fixed. Otherwise, a stale reference points at an empty or hidden element.
+- **Or keep the element** in the page, and change its **text.** An empty element gives an empty description.
+- **Use `aria-invalid="true"`** only while the error is real.
+- **Do not point to elements that are removed** from the page. Recreate them with the same id, or update the reference.
+
+```js
+// When an error appears
+input.setAttribute("aria-invalid", "true");
+input.setAttribute("aria-describedby", "email-hint email-error");
+error.textContent = "Error: Enter an email address that includes an @ sign.";
+
+// When it is fixed
+input.removeAttribute("aria-invalid");
+input.setAttribute("aria-describedby", "email-hint");
+error.textContent = "";
+```
+
+## Tooltips and WCAG 1.4.13
+
+A tooltip is a small box of help that appears when you hover over or focus a control. Done badly, it is a trap: it vanishes when you try to reach it, it covers content, and keyboard users never see it. WCAG **1.4.13 (Content on Hover or Focus)** sets three rules:
+
+1. **Dismissible:** there is a way to close it (usually **Esc**) without moving the pointer or the focus.
+2. **Hoverable:** the pointer can move **onto** the tooltip without it disappearing (important for people who magnify the screen).
+3. **Persistent:** it stays until the person moves away, dismisses it, or the information is no longer valid.
+
+And for keyboard users: it must appear on **focus,** not only on hover.
+
+
+
+
+```html title="index.html"
+
+ Help
+ Opens the help centre in a new tab.
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Also remember that **touch screens have no hover.** If the help matters, make it visible text in the page, rather than a tooltip. And keep a tooltip to **plain text:** if it has links or buttons, it is a popup or a dialog, and needs a different pattern.
+
+## Related Tools
+
+- **`aria-description`** is a newer attribute that lets you write a description as a string, in the same way `aria-label` writes a name. Support has been growing, but it is not yet as reliable as `aria-describedby`, and it is invisible and may not be translated. Prefer pointing to real text.
+- **`aria-details`** points to richer content, as mentioned above.
+- **`title`** can act as a tooltip, and as a fallback description when a control already has a name. It is not shown to keyboard or touch users, so do not depend on it.
+- **`aria-errormessage`** is designed for errors, but support is patchy. `aria-describedby` with `aria-invalid` is the safer choice today.
+
+## Check Your Descriptions
+
+The checker below finds the usual problems: references to ids that do not exist, duplicate ids, empty referenced elements, links or controls inside a description, descriptions that repeat the name, invalid fields with no message, placeholders used as hints, and tooltips that nothing points to.
+
+
+<>
+
+>
+
+
+On a real site, you can also:
+
+1. **Open DevTools,** select a control, and look in the Accessibility panel. Many browsers show the computed description.
+2. **Use a screen reader,** Tab to each field and button, and listen for the description after the name.
+3. **Try it with descriptions turned off** (most screen readers have a verbosity setting), and see what is lost.
+4. **Trigger every error,** and check that the description changes when the error appears and disappears.
+5. **Test tooltips** with a keyboard, a pointer, and a magnifier.
+
+## Good Habits
+
+1. **Use a name for what it is,** and a description for extra help.
+2. **Keep descriptions short and plain,** with no links or formatting.
+3. **Show the same help on screen** that you connect with `aria-describedby`.
+4. **Never put essential information only in a description.**
+5. **Link errors only while they are showing,** and set `aria-invalid` to match.
+6. **Use unique ids,** and check every referenced id exists.
+7. **Back urgent messages with a live region** or focus.
+8. **Make tooltips dismissible, hoverable, persistent,** and visible on focus.
+9. **Test with a screen reader,** with descriptions on and off.
+
+## Accessibility Checklist
+
+- [ ] Every id in every `aria-describedby` exists, is unique, and has text.
+- [ ] Descriptions hold extra help, not the name.
+- [ ] Anything essential is also in the label or the visible text.
+- [ ] Hints and errors are visible on the page, as well as connected.
+- [ ] Invalid fields have `aria-invalid` and a linked, specific error message.
+- [ ] References to errors are added and removed as the errors appear and disappear.
+- [ ] Descriptions are short, plain text, with no links or buttons.
+- [ ] Tooltips appear on focus and hover, can be dismissed, and can be hovered.
+- [ ] Dialogs have both a name and a description where it helps.
+- [ ] The page was tested with a screen reader and a keyboard.
+
+## Security and Privacy Notes
+
+- **Descriptions are read aloud and are in the page source.** Do not put private data, account details, or internal notes in them.
+- **Be careful with error text.** A description such as "This email is already registered" tells anyone, including attackers, who has an account. Many sites use a general message and explain it elsewhere.
+- **Password help is fine,** but do not reveal the exact rules an attacker could exploit, or the user's current password in any form.
+- **Do not use descriptions to mislead.** A description that contradicts the visible text ("This is free" when it costs money) is deceptive, and it harms people who cannot see the screen.
+- **Do not hide warnings only in descriptions.** Consent, charges, and privacy terms must be visible to everyone, and not read out only to some people.
+- **Clean user-supplied text** before placing it in a described element, to prevent script injection.
+- **Tooltips from third parties** (embedded widgets) can add misleading text. Check what they show.
+
+## Global Audience Notes
+
+- **Translate descriptions** with the rest of the page. Because they point to real text, translation tools handle them well, which is an advantage over `aria-description`.
+- **Keep them short and in plain language.** They are heard once, in a second language for many people, and long text is easily lost.
+- **Show formats clearly:** "Date: day/month/year," with an example. Formats such as dates, phone numbers, and decimals differ across regions.
+- **Right-to-left languages:** check how punctuation and mixed text (numbers, Latin words) read, and that tooltips appear on the correct side.
+- **Set the `lang` attribute** on the page, and on any description in another language, so the voice reads it correctly.
+- **Low connectivity and old devices:** do not rely on a script to show help that matters. Put hints in the HTML, visible from the start.
+- **Cultural differences:** avoid idioms and jokes in help text. A literal, polite sentence travels best.
+
+## Common Mistakes to Avoid
+
+1. **Using `aria-describedby` instead of a label.**
+2. **Essential information only in the description.**
+3. **A typo in the id,** so nothing is described.
+4. **Pointing to an empty element.**
+5. **Links and buttons inside the described text.**
+6. **Leaving an old error linked** after it is fixed.
+7. **An error with no `aria-invalid`,** or `aria-invalid` with no message.
+8. **Descriptions that repeat the name.**
+9. **Hidden help that sighted people cannot see.**
+10. **Tooltips that only show on hover,** or cannot be dismissed.
+11. **Long, paragraph-length descriptions.**
+12. **Never testing with descriptions off.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the description builder with each example, and turn descriptions off. What does the listener lose?
+2. Add the error to the password example, and explain when its id should be added and removed.
+3. Use the tooltip lab with each option off in turn, and write down which rule it breaks.
+4. Run the checker on the messy HTML, fix every problem, and run it again.
+5. Add a hint and an error to a form field in a page you built, and test with a screen reader.
+6. Give a "Delete" button a description that explains the consequence.
+7. Name a dialog with `aria-labelledby`, and describe it with `aria-describedby`.
+8. Replace a `title` tooltip with a proper tooltip that works with a keyboard.
+9. Find a site with a tooltip that vanishes when you move the pointer onto it. Which rule does it break?
+10. Bonus: build a form field whose description updates as the error is added and removed, with a live region for the change.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What is the difference between a name and a description?
+
+A name says what a control is, in a few words. A description adds extra help, such as a rule or a warning, in a sentence or two. It is read after the name, role, and state.
+
+
+
+
+2. What does `aria-describedby` take as its value?
+
+A list of ids of elements already on the page. Their text is joined, in order, to make the description.
+
+
+
+
+3. Why should essential information not live only in a description?
+
+Many screen reader users turn descriptions off, and descriptions are not read when moving through lists of links or fields. Essential facts belong in the label or visible text.
+
+
+
+
+4. How should you handle an error that appears and disappears?
+
+Add the error's id (and `aria-invalid="true"`) when it appears, and remove them when it is fixed. Back urgent errors with a live region or focus.
+
+
+
+
+5. What are the three rules for tooltips in WCAG 1.4.13?
+
+They must be dismissible (for example with Esc), hoverable (the pointer can move onto them), and persistent (they stay until you move away or dismiss them).
+
+
+
+
+6. What happens to links inside described text?
+
+They are flattened into plain text, and cannot be used. Keep descriptions simple, and use `aria-details` or visible content for anything richer.
+
+
+
+## Key Takeaways
+
+- **A name says what it is. A description says what else you should know.**
+- **`aria-describedby` points to text already on the page,** in the order you list the ids.
+- **Descriptions can be missed.** Never put essential information only in one, and always show help on screen.
+- **Keep them short, plain, and accurate.** Add and remove error ids as errors come and go.
+- **Tooltips must be dismissible, hoverable, persistent,** and visible on focus, as well as hover.
+- **Use `aria-invalid` and a linked message** for errors, with a live region or focus for urgent ones.
+- **Test with a screen reader,** with descriptions on and off.
+
+Next, learn how to hide things from assistive technology, and when not to, in [aria-hidden](./aria-hidden).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/aria-hidden.mdx b/tutorials/html/13-accessibility/aria-hidden.mdx
index e69de29bb..73c59d001 100644
--- a/tutorials/html/13-accessibility/aria-hidden.mdx
+++ b/tutorials/html/13-accessibility/aria-hidden.mdx
@@ -0,0 +1,679 @@
+---
+id: html-aria-hidden
+title: "Aria Hidden"
+sidebar_label: "Aria Hidden"
+sidebar_position: 20
+slug: /html/accessibility/aria-hidden
+description: "Learn what aria-hidden really does, when it is safe, and when it quietly breaks your page. Compare hidden, display none, visibility hidden, aria-hidden, inert, and visually hidden text. Never put aria-hidden on something focusable. Includes a hiding-methods lab with real focus tests, a modal background lab, and a live aria-hidden checker."
+keywords:
+ - aria-hidden
+ - hide from screen readers
+ - decorative icons accessibility
+ - inert attribute
+ - hidden attribute
+ - visually hidden text
+ - sr-only
+ - aria-hidden focusable
+ - modal background accessibility
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, aria, aria-hidden, inert, icons, modals, a11y, frontend]
+---
+
+import {useState, useRef, useEffect} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const hideMethods = [
+ {id: "none", label: "Nothing (normal)", screen: "Yes", tree: "Yes", focus: "Yes", note: "The button is shown, it is in the accessibility tree, and the keyboard can reach it. This is the starting point."},
+ {id: "hidden", label: "The hidden attribute", screen: "No", tree: "No", focus: "No", note: "The element is gone for everyone. This is the simplest, safest way to hide something from everybody."},
+ {id: "display", label: "CSS display: none", screen: "No", tree: "No", focus: "No", note: "Same result as the hidden attribute, but set from CSS. Gone for everyone."},
+ {id: "visibility", label: "CSS visibility: hidden", screen: "No", tree: "No", focus: "No", note: "The space is kept, but the content is gone for everyone, and cannot be focused."},
+ {id: "aria", label: 'aria-hidden="true"', screen: "Yes", tree: "No", focus: "Yes", note: "Still on screen, and still focusable. Only the screen reader loses it. A keyboard user can land on a control that a screen reader cannot see. This is the trap."},
+ {id: "inert", label: "The inert attribute", screen: "Yes", tree: "No", focus: "No", note: "Still on screen, but it cannot be focused, clicked, or selected, and browsers also hide it from assistive technology. It is the right tool for 'visible but switched off'."},
+ {id: "sronly", label: "Visually hidden (sr-only CSS)", screen: "No", tree: "Yes", focus: "Yes", note: "The opposite of aria-hidden: invisible on screen, but still read by a screen reader. Used for extra text that only a listener needs."},
+];
+
+export function HidingMethodsLab() {
+ const [method, setMethod] = useState("none");
+ const [result, setResult] = useState("");
+ const boxRef = useRef(null);
+ const btnRef = useRef(null);
+ useEffect(() => {
+ const box = boxRef.current;
+ if (!box) return;
+ box.removeAttribute("hidden");
+ box.removeAttribute("aria-hidden");
+ box.removeAttribute("inert");
+ box.style.cssText = "";
+ if (method === "hidden") box.setAttribute("hidden", "");
+ if (method === "display") box.style.display = "none";
+ if (method === "visibility") box.style.visibility = "hidden";
+ if (method === "aria") box.setAttribute("aria-hidden", "true");
+ if (method === "inert") box.setAttribute("inert", "");
+ if (method === "sronly") box.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;";
+ setResult("");
+ }, [method]);
+ const current = hideMethods.find((item) => item.id === method);
+ const testFocus = () => {
+ const button = btnRef.current;
+ if (!button) return;
+ if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
+ button.focus();
+ const reached = document.activeElement === button;
+ if (reached) {
+ setResult(method === "aria" ? "Focus reached the button. A keyboard user can land here, but a screen reader is told nothing is there. That mismatch is the problem." : "Focus reached the button.");
+ } else {
+ setResult("Focus could not reach the button. It cannot be used by keyboard, and nothing is left to confuse anyone.");
+ }
+ };
+ return (
+
+
+ Choose how to hide the button
+ {hideMethods.map((item) => (
+
+ setMethod(item.id)} /> {item.label}
+
+ ))}
+
+
+
+ Test: can focus reach the button?
+
+
{result}
+
+ Seen on screen: {current.screen}
+ In the accessibility tree (screen readers): {current.tree}
+ Can receive keyboard focus: {current.focus}
+
+
{current.note}
+
+ The focus test is real: it tries to focus the actual button on this page. The three lines above describe what the standards and current browsers normally do, and they cannot be measured from inside the page. Details can vary a little between browsers and screen readers, so test the pages you build.
+
+
+ );
+}
+
+export function ModalBackgroundLab() {
+ const [mode, setMode] = useState("inert");
+ const [open, setOpen] = useState(false);
+ const [report, setReport] = useState("");
+ const bgRef = useRef(null);
+ const openerRef = useRef(null);
+ const closeRef = useRef(null);
+ const targetRef = useRef(null);
+ const wasOpen = useRef(false);
+ useEffect(() => {
+ const bg = bgRef.current;
+ if (!bg) return;
+ bg.removeAttribute("aria-hidden");
+ bg.removeAttribute("inert");
+ if (open && mode === "aria") bg.setAttribute("aria-hidden", "true");
+ if (open && mode === "inert") bg.setAttribute("inert", "");
+ setReport("");
+ }, [open, mode]);
+ useEffect(() => {
+ if (open && closeRef.current) closeRef.current.focus();
+ if (!open && wasOpen.current && openerRef.current) openerRef.current.focus();
+ wasOpen.current = open;
+ }, [open]);
+ const tryEscape = () => {
+ const target = targetRef.current;
+ if (!target) return;
+ target.focus();
+ const escaped = document.activeElement === target;
+ setReport(escaped ? "Focus escaped to the page behind the dialog. A keyboard user can now operate hidden controls, and a screen reader says nothing about them." : "Blocked. Focus stayed inside the dialog.");
+ if (escaped) target.blur();
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ return (
+
+
+ How should the page behind the dialog be handled?
+ setMode("none")} /> Do nothing
+ setMode("aria")} /> aria-hidden="true" only
+ setMode("inert")} /> inert (recommended)
+
+
+
The page behind
+
Recipes
+
Settings
+
setOpen(true)}>Open dialog
+
+ {open ? (
+
+
Delete recipe?
+
Press Tab to move around. Press Esc to close.
+
setOpen(false)} style={{marginRight: "0.5rem"}}>Close
+
Test: try to focus "Recipes" behind
+
+ ) : null}
+
{report}
+
+ Open the dialog, then press Tab several times. With "Do nothing" or "aria-hidden only," focus can leave the dialog and wander into the page behind. With inert, it cannot. This small dialog is only a demonstration. A real modal also needs a focus trap or inert background, a close button, Esc handling, and focus returned to the opener. See Focus Management .
+
+
+ );
+}
+
+export const hiddenSamples = {
+ messy: 'Close \n\n★ Favourite\n Download\n \nAt least 12 characters.
\n \nSection title \nMaybe visible
\nAlready hidden
\nEmpty value
\nNext slide
',
+ fixed: 'Close \nMenu \n★ Favourite\n Download\n \nAt least 12 characters.
\n \nSection title \nVisible to everyone
\nHidden from everyone
\nNext slide
',
+};
+
+export const hiddenFocusSelector = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex], [contenteditable=""], [contenteditable="true"]';
+
+export function hiddenFocusables(root) {
+ const all = [root].concat(Array.from(root.querySelectorAll(hiddenFocusSelector)));
+ return all.filter((el) => el.matches(hiddenFocusSelector) && !el.hasAttribute("disabled"));
+}
+
+export function HiddenCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(hiddenSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const referenced = {};
+ Array.from(doc.querySelectorAll("[aria-labelledby], [aria-describedby]")).forEach((el) => {
+ ((el.getAttribute("aria-labelledby") || "") + " " + (el.getAttribute("aria-describedby") || "")).split(/\s+/).filter(Boolean).forEach((id) => { referenced[id] = true; });
+ });
+ const rows = [];
+ Array.from(doc.querySelectorAll("[aria-hidden]")).forEach((el) => {
+ const tag = el.tagName.toLowerCase();
+ const value = el.getAttribute("aria-hidden");
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
+ const issues = [];
+ const notes = [];
+ if (value !== "true" && value !== "false") issues.push("The value \"" + value + "\" is not valid. Use aria-hidden=\"true\", or remove the attribute.");
+ if (value === "false") notes.push("aria-hidden=\"false\" is not a reliable way to show something. If the element is hidden another way, it stays hidden. Remove the attribute, and fix the real cause.");
+ if (value === "true") {
+ const focusables = hiddenFocusables(el);
+ const reachable = focusables.filter((item) => !(item.hasAttribute("tabindex") && parseInt(item.getAttribute("tabindex"), 10) < 0) && !item.closest("[inert], [hidden]"));
+ const scripted = focusables.filter((item) => reachable.indexOf(item) === -1 && !item.closest("[inert], [hidden]"));
+ if (reachable.length > 0) issues.push("Contains " + reachable.length + " focusable item" + (reachable.length > 1 ? "s" : "") + " (" + reachable.map((item) => item.tagName.toLowerCase()).join(", ") + "). A keyboard user can reach them, but a screen reader cannot see them. Use hidden or inert, or remove the aria-hidden.");
+ if (scripted.length > 0) notes.push("Contains " + scripted.length + " item" + (scripted.length > 1 ? "s" : "") + " with tabindex=\"-1\". They are out of the Tab order, but a click or a script can still focus them. Prefer inert.");
+ if (el.hasAttribute("hidden") || /display\s*:\s*none/i.test(el.getAttribute("style") || "")) notes.push("Already hidden for everyone, so aria-hidden does nothing extra. Remove it.");
+ if (/^h[1-6]$/.test(tag) || el.querySelector("h1, h2, h3, h4, h5, h6") || ["main", "nav", "header", "footer", "aside"].indexOf(tag) !== -1) notes.push("This hides a heading or a landmark, so screen reader users lose a way to navigate. Only do this if the same structure is available elsewhere.");
+ if (tag === "img" && (el.getAttribute("alt") || "").trim() !== "") notes.push("The image has alt text (\"" + el.getAttribute("alt") + "\"), but aria-hidden stops it being read. Either the image is decorative (use alt=\"\" and remove aria-hidden) or it carries meaning (remove aria-hidden).");
+ if (el.id && referenced[el.id]) notes.push("Another element points to this one with aria-labelledby or aria-describedby. It is still used for the name or description even though it is hidden. Check that is what you want, and that sighted users can also see the help.");
+ if (text.length > 40 && /[a-zA-Z]{4,}/.test(text)) notes.push("This is a lot of real text (" + text.length + " characters). Check that listeners are not missing something important.");
+ }
+ rows.push({tag, text, issues, notes});
+ });
+ Array.from(doc.querySelectorAll("svg, i, span")).forEach((el) => {
+ const tag = el.tagName.toLowerCase();
+ if (el.closest("[aria-hidden='true']")) return;
+ const cls = (el.getAttribute("class") || "");
+ const looksLikeIcon = tag === "svg" || (/icon|fa-|glyph/i.test(cls) && !/[a-zA-Z0-9]/.test(el.textContent || ""));
+ if (!looksLikeIcon) return;
+ if (el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") || el.hasAttribute("role") || el.querySelector("title")) return;
+ rows.push({tag: tag + (cls ? "." + cls.split(/\s+/)[0] : ""), text: "", issues: [], notes: ["An icon with no name and no aria-hidden. If it is decoration, add aria-hidden=\"true\"" + (tag === "svg" ? " and focusable=\"false\"" : "") + ". If it carries meaning, give it a name."]});
+ });
+ if (/]*aria-hidden/i.test(html)) rows.push({tag: "body", text: "", issues: ["aria-hidden on the body hides the whole page from screen readers."], notes: []});
+ const bad = rows.reduce((sum, row) => sum + row.issues.length, 0);
+ const choose = (value) => { setWhich(value); setHtml(hiddenSamples[value]); };
+ return (
+
+
+ Which HTML do you want to check?
+ choose("messy")} /> Messy aria-hidden
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} items checked, {bad} problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + ">"} {row.text ? {'"' + (row.text.length > 40 ? row.text.slice(0, 40) + "..." : row.text) + '"'}{" "} : null}
+ {row.issues.length === 0 && row.notes.length === 0 ? OK : null}
+ {row.issues.length > 0 ? Problem: {row.issues.join(" ")} : null}
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+
+ This reads the HTML only. It cannot see CSS from your stylesheets or changes made by scripts, and it cannot tell whether hidden content is important. Test the real page with a keyboard and a screen reader.
+
+
+ );
+}
+
+export function HiddenChecker() {
+ return {() => } ;
+}
+
+You have probably met `aria-hidden` already, usually on a little icon: `★ `. It looks harmless, and used well, it is. But it is also one of the **most misused** attributes in ARIA, and a single wrong use can make a button invisible to a screen reader while still sitting in the keyboard's path.
+
+`aria-hidden="true"` has one job: **it tells assistive technology to ignore an element and everything inside it.** It does not hide anything on screen. It does not stop the keyboard. It only edits what the screen reader is told. Once you understand that, you will know exactly when to use it, and when to reach for something else.
+
+
+
+
+## What You Will Learn
+
+- What **`aria-hidden="true"`** does, and what it does **not** do
+- The safest rule: **never put it on something focusable**
+- When it is **right:** decorative icons, duplicated content, and hidden slides
+- How it differs from **`hidden`, `display: none`, `visibility: hidden`, `inert`,** and **visually hidden text**
+- How to treat the **page behind a modal** dialog
+- How hidden content still affects **names and descriptions**
+- How to **check** your pages
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+
+
+ Add to favourites
+
+
+
+🍞 Rye bread
+
+
+Close
+
+
+Close
+
+
+ ...
+ ...
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Pick each method in turn, then press the focus test. Watch what happens with `aria-hidden`: the button is still on screen, and focus can still land on it.
+
+:::info What it does, in one sentence
+`aria-hidden="true"` removes an element, and **everything inside it,** from the **accessibility tree.** Nothing else changes: it is still drawn, still clickable, and still reachable with Tab.
+:::
+
+## The Rule That Matters Most
+
+The ARIA rules say it plainly: **do not use `aria-hidden="true"` on a focusable element, or on an element that contains one.**
+
+Why? Think of what happens to a keyboard and screen reader user:
+
+1. They press Tab and land on the button.
+2. The screen reader has been told that button does not exist.
+3. They hear **nothing,** or something confusing like "blank." They are now focused on a control they cannot identify, and cannot tell why.
+
+That is a **ghost control.** It is worse than a missing one, because the user feels lost.
+
+Some browsers now **warn in the developer tools,** or even refuse to apply `aria-hidden`, when the focused element is inside a hidden area. Do not rely on that. Avoid the situation.
+
+### Fixing it
+
+| If you want... | Use... |
+| :-- | :-- |
+| It gone for **everyone** | The `hidden` attribute (or `display: none`) |
+| It **visible but switched off** (a dimmed page behind a dialog) | `inert` |
+| It **removed from the screen reader only,** and it is not focusable | `aria-hidden="true"` |
+| It **invisible on screen** but read to listeners | Visually hidden ("sr-only") text |
+
+## How the Hiding Methods Compare
+
+| Method | Seen on screen? | Read by screen readers? | Focusable? |
+| :-- | :-- | :-- | :-- |
+| `hidden` or `display: none` | No | No | No |
+| `visibility: hidden` | No (space kept) | No | No |
+| `aria-hidden="true"` | **Yes** | **No** | **Yes** (danger) |
+| `inert` | Yes (can be dimmed) | No | No |
+| Visually hidden ("sr-only") | **No** | **Yes** | Yes, if it is a control |
+| `opacity: 0` or moved off-screen with no other care | No | **Yes** | Yes, if it is a control |
+
+Two things to notice:
+
+- **`aria-hidden` and visually hidden text are opposites.** One hides from listeners but not viewers. The other hides from viewers but not listeners.
+- **Only the first three rows remove focus.** `aria-hidden` does not.
+
+(These are the normal results. Browsers and assistive technology can differ in small ways, so always test.)
+
+## Safe Uses
+
+`aria-hidden="true"` is the right choice when an element is **not focusable** and would only **add noise or repeat** something a listener already has.
+
+### Decorative icons and emoji
+
+If an icon sits next to text that already says the same thing, hide the icon. Otherwise, a screen reader may read out something like "star" or the emoji's name, before "Add to favourites."
+
+```html
+
+
+ Download
+
+```
+
+For **inline SVG,** add `focusable="false"` too. Old versions of Internet Explorer and Edge made SVG focusable by default, and the habit is harmless in modern browsers.
+
+For an ` `, you do not need `aria-hidden`. Use an **empty alt** (`alt=""`) for decoration, as covered in [Alt Text](./alt-text).
+
+:::warning Do not hide an icon that is the only label
+If a button contains **only** an icon, hiding the icon leaves the button **with no name,** which is a serious failure. Give the button a name with `aria-label` or visually hidden text, and then hide the icon. See [Accessible Names](./accessible-names).
+
+```html
+
+
+
+```
+:::
+
+### Decoration that would be read as noise
+
+Dividers made of characters (`* * *`), fancy bullets, "breadcrumb" separators (`>` or `/`), and repeated arrows are good candidates. For lists and breadcrumbs, prefer CSS (`::before` with a separator) so there is no extra text in the page at all.
+
+### Duplicate content
+
+Sometimes the same text appears twice: a visible, styled version, and a plain one for screen readers. Hide one of them so it is not read twice.
+
+### Carousel slides and tab panels that are off-screen
+
+A slide that is not showing should be hidden from **everyone,** not only from listeners. Use `hidden` or `display: none`. If you must keep it on the page for an animation, use `inert` so the controls inside cannot be reached, and (optionally) `aria-hidden="true"` for older assistive technology. More in [ARIA Roles](./aria-roles) for tabs and carousels.
+
+### The page behind a modal
+
+A dialog that takes over the screen should make the page behind it **unavailable.** The modern, simple way is **`inert`** on the page behind. Try it in the lab below.
+
+
+
+
+```html title="modal.html"
+
+
+ Open dialog
+ ...
+
+
+
+
+
Delete recipe?
+ Close
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+In the lab, choose "aria-hidden only," open the dialog, and press **Test: try to focus "Recipes" behind.** Focus escapes. That is the trap. Now try `inert`: blocked.
+
+:::tip Prefer inert, or the native dialog
+The native `` element, opened with `showModal()`, already makes the rest of the page inert and traps focus for you. If you can use it, start there. If you cannot, set `inert` on the content behind the dialog. Use `aria-hidden` there only as a backup for very old browsers, and never as the only protection.
+:::
+
+:::info Browser support for inert
+`inert` is supported in the current versions of the major browsers, but older browsers and some embedded web views may lack it. Check a current support table such as MDN or Can I Use, and test in the browsers your readers use.
+:::
+
+## Visually Hidden Text: The Opposite Trick
+
+Sometimes a listener needs more than a viewer sees. A common example is an icon-only button, or a link that says "Read more" with a hidden suffix: "Read more **about rye bread**."
+
+Hide the text **visually,** keep it in the accessibility tree. This is often called a **"sr-only"** or **"visually-hidden"** class:
+
+```css title="style.css"
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
+```
+
+```html title="index.html"
+Read more about rye bread
+```
+
+**Do not** use `display: none` or `hidden` for this. Those remove the text from screen readers too. And do not use `opacity: 0` or `font-size: 0`, which behave unpredictably.
+
+:::warning Visually hidden is not "invisible ink"
+Use it for **short, real help** for listeners. Do not hide long text, keyword lists, or content that sighted users would object to if they could see it. It is poor practice and search engines can penalise it.
+:::
+
+## aria-hidden and Names
+
+Hidden does not always mean **ignored.** Two rules to remember:
+
+- **A hidden element can still name something.** If `aria-labelledby` or `aria-describedby` points to an element that is hidden (even with `aria-hidden="true"` or `hidden`), the text is **still used.** That is useful, because you can keep a label off-screen. It is also a trap if you forgot the label was there. See [aria-labelledby](./aria-labelledby).
+- **Children are hidden too.** You cannot hide a parent and show a child. Once `aria-hidden="true"` is on a container, **everything inside it is gone,** whatever the children say.
+
+## aria-hidden="false" Is Not a Fix
+
+You might expect `aria-hidden="false"` to say "show this." It does not work that way:
+
+- If an element is hidden another way (`display: none`, `hidden`), `aria-hidden="false"` **does not bring it back.**
+- Its effect inside a parent that is `aria-hidden="true"` is **not reliable.**
+
+If you want something visible to everyone, **remove the attribute** and fix the real cause.
+
+## Keep It in Sync
+
+If you toggle `aria-hidden` with JavaScript, tie it to **the same state as the visual change.** The common bug is one of these:
+
+- A panel is closed on screen, but `aria-hidden` is still `false`. Listeners hear content that does not exist.
+- A panel is open on screen, but `aria-hidden` is still `true`. Listeners miss the main content.
+
+Better still, **use `hidden` or `inert`.** They change what the screen shows, what the keyboard can reach, **and** what the screen reader hears, together. There is one state to keep correct, not three.
+
+## Check Your Pages
+
+
+
+
+```html title="check.html"
+
+Close
+
+
+Maybe
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Run the messy sample, fix every problem in the box, then try the fixed version.
+
+### Check it by hand
+
+1. **Search your code for `aria-hidden`.** For each one, ask: *is it focusable, or does it contain something focusable?*
+2. **Tab through the page.** Does focus ever land on something with no visible or spoken name?
+3. **Open each dialog and menu.** Can Tab reach the page behind?
+4. **Turn on a screen reader.** Walk over your icons. Do you hear noise like "graphic, star"?
+5. **Browser developer tools:** the **Accessibility** panel shows what is, and is not, in the tree for the selected element.
+6. **Run an automated tool** such as axe DevTools, Lighthouse, or WAVE. They flag many focusable `aria-hidden` cases.
+
+## Good Habits
+
+- **Use `hidden` or `inert` first.** Only reach for `aria-hidden` for decoration and duplicates.
+- **Never on focusable elements,** or on a parent of one.
+- **Never on the ``** or on the main content.
+- **Hide icons that sit beside text,** but give icon-only buttons a name.
+- **Add `focusable="false"` to decorative inline SVG.**
+- **Use visually hidden text** when only listeners need more.
+- **Keep `aria-hidden` and the visible state in sync,** or avoid toggling it at all.
+- **Test with a keyboard and a screen reader.**
+
+## Accessibility Checklist
+
+- [ ] No `aria-hidden="true"` on, or around, anything focusable
+- [ ] Hidden slides, panels, and menus use `hidden`, `display: none`, or `inert`
+- [ ] The page behind a modal is inert (or the native `` is used)
+- [ ] Decorative icons and emoji are hidden, and their text is still there
+- [ ] Icon-only buttons and links still have a name
+- [ ] No `aria-hidden="false"` used as a way to show things
+- [ ] No important content (headings, landmarks, alt text) is hidden by mistake
+- [ ] Visually hidden text is short, real, and helpful
+- [ ] `aria-hidden` always matches the visible state
+- [ ] Tested with a keyboard and a screen reader
+
+## Security and Privacy Notes
+
+- **Hiding is not protecting.** `hidden`, `aria-hidden`, `inert`, and CSS hiding only change how a page is **shown.** The content is still in the HTML. Anyone can read it with "view source," developer tools, or a scraper. Never put passwords, keys, or private data in a page just because you hid it.
+- **Invisible text is still read.** Text that is hidden from sight, but kept for screen readers, can also be read by search engines and by automated tools such as AI crawlers and summarisers. Do not hide instructions or claims there that you would not show to a person.
+- **Do not hide important notices from listeners** (cookie consent choices, charges, terms). Hiding information from some users while showing it to others is unfair, and may be a legal problem in some places. This is not legal advice. Check the rules that apply to you.
+- **Hiding something does not disable it for a determined user.** Disabled or hidden buttons can be re-enabled in developer tools. Always check permissions on the server.
+
+## Global Audience Notes
+
+- **Emoji are read in the listener's language,** and different screen readers read them differently. A decorative emoji can be noisy ("bread," "loaf of bread") in every language. If it only decorates, hide it. If it carries meaning (a status, a mood), give it text.
+- **Translation tools translate hidden text too.** Visually hidden text and `aria-label` values are usually translated with the page, so write them as short, plain words that translate well.
+- **Right-to-left languages:** arrows and chevrons may need to be mirrored. Decorative ones are hidden from listeners either way, but check them visually.
+- **Icon fonts can fail on slow networks,** showing empty boxes or odd letters. Where possible, use inline SVG, and keep a real text label beside the icon.
+- **Not every device has inert support.** If your readers use older phones or embedded browsers, test your modals there.
+
+## Common Mistakes to Avoid
+
+1. **`aria-hidden="true"` on a button or link.** This is the big one: a ghost control.
+2. **Hiding a whole container that contains links or buttons.**
+3. **Using `aria-hidden` to hide something "for everyone."** It does not hide it on screen. Use `hidden`.
+4. **Using `aria-hidden` alone for the page behind a dialog.** Focus can still escape. Use `inert`.
+5. **Hiding the only label of a control,** such as the icon in an icon-only button.
+6. **`aria-hidden="false"` expecting it to reveal something.**
+7. **`display: none` for visually hidden text.** It removes it from screen readers too.
+8. **Hiding headings or landmarks,** which breaks navigation.
+9. **Hiding `alt` text by putting `aria-hidden` on an image that carries meaning.**
+10. **Forgetting that hidden labels are still used,** and then leaving stale text behind.
+11. **Out-of-sync toggles:** the screen shows one thing and `aria-hidden` says another.
+12. **Never testing with a keyboard.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the hiding methods lab. For each method, press the focus test, and write down what happens.
+2. In the modal lab, choose each mode in turn. Which mode lets focus escape, and why?
+3. Run the checker on the messy sample, fix every problem, and run it again.
+4. Search a page you built for `aria-hidden`. Find one use that is wrong.
+5. Give an icon-only button a name, and then hide its icon properly.
+6. Build a "Read more" link with visually hidden text, and listen to it with a screen reader.
+7. Replace a closed panel's `aria-hidden` toggle with the `hidden` attribute.
+8. Open your browser's Accessibility panel, select a hidden element, and look at the tree.
+9. Turn on a screen reader and walk through a page full of emoji. Which ones should be hidden?
+10. Bonus: build a modal with the native `` element, then with `inert`, and compare how they behave.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What does `aria-hidden="true"` do?
+
+It removes an element, and everything inside it, from the accessibility tree, so screen readers ignore it. It does not hide it on screen, and it does not stop the keyboard.
+
+
+
+
+2. Why must you never put it on a focusable element?
+
+A keyboard user can still Tab onto the control, but the screen reader has been told it does not exist. They land on a "ghost" with no name, and cannot tell what it is.
+
+
+
+
+3. Which should you use to hide something from everyone?
+
+The `hidden` attribute, or `display: none`. Both remove it from the screen, the accessibility tree, and the focus order.
+
+
+
+
+4. What is the best way to switch off the page behind a modal?
+
+Set `inert` on the content behind the dialog, or use the native `<dialog>` element with `showModal()`. `aria-hidden` alone does not stop focus escaping.
+
+
+
+
+5. How is visually hidden text different from aria-hidden?
+
+Visually hidden text is invisible on screen but still read by screen readers. `aria-hidden` is the opposite: visible on screen, but ignored by screen readers.
+
+
+
+
+6. Does a hidden element still work as a label?
+
+Yes. If `aria-labelledby` or `aria-describedby` points to a hidden element, its text is still used for the name or description.
+
+
+
+## Key Takeaways
+
+- **`aria-hidden="true"` only edits what screen readers hear.** It does not hide anything on screen, and does not block the keyboard.
+- **Never use it on, or around, a focusable element.** That creates ghost controls.
+- **Use `hidden` or `inert` to really switch something off.** Use `aria-hidden` for decoration and duplicates.
+- **Hide decorative icons, but name icon-only buttons.**
+- **The page behind a modal should be `inert`,** or use the native ``.
+- **Visually hidden text is the opposite trick:** seen by listeners, not by viewers.
+- **Hidden is not secure.** The content is still in the page.
+- **Test with a keyboard and a screen reader.**
+
+Next, learn how to announce changes to people who cannot see them happen, in [aria-live](./aria-live).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/aria-introduction.mdx b/tutorials/html/13-accessibility/aria-introduction.mdx
index e69de29bb..ac9d1f6a7 100644
--- a/tutorials/html/13-accessibility/aria-introduction.mdx
+++ b/tutorials/html/13-accessibility/aria-introduction.mdx
@@ -0,0 +1,555 @@
+---
+id: html-aria-introduction
+title: "Introduction to ARIA"
+sidebar_label: "Introduction to ARIA"
+sidebar_position: 14
+slug: /html/accessibility/aria-introduction
+description: "Learn what ARIA is and when to use it: roles, properties and states, the five rules of ARIA, why native HTML comes first, what ARIA can and cannot do, and how to check it. Includes a lab that shows ARIA changes meaning but not behaviour, a 'do I need ARIA?' guide, and a live ARIA checker."
+keywords:
+ - ARIA
+ - WAI-ARIA
+ - ARIA roles
+ - ARIA states and properties
+ - first rule of ARIA
+ - native HTML first
+ - aria-label
+ - accessible rich internet applications
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, aria, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export function AriaBehaviourLab() {
+ const [tabbable, setTabbable] = useState(false);
+ const [keys, setKeys] = useState(false);
+ const empty = {focus: 0, clicks: 0, enter: 0, space: 0, actions: 0};
+ const [fake, setFake] = useState(empty);
+ const [real, setReal] = useState(empty);
+ const bump = (setter, field, extra) => setter((previous) => ({...previous, [field]: previous[field] + 1, actions: previous.actions + (extra ? 1 : 0)}));
+ const onFakeKey = (event) => {
+ if (event.key === "Enter") bump(setFake, "enter", keys);
+ if (event.key === " ") {
+ if (keys) event.preventDefault();
+ bump(setFake, "space", keys);
+ }
+ };
+ const onRealKey = (event) => {
+ if (event.key === "Enter") setReal((previous) => ({...previous, enter: previous.enter + 1}));
+ if (event.key === " ") setReal((previous) => ({...previous, space: previous.space + 1}));
+ };
+ const box = {border: "1px solid #94a3b8", borderRadius: "8px", padding: "0.7rem", flex: "1 1 14rem"};
+ return (
+
+
+ Help the made-up button along (the real one needs nothing)
+ setTabbable(event.target.checked)} /> Add tabindex="0" so Tab can reach it
+ setKeys(event.target.checked)} /> Write script so Enter and Space work
+
+
+
+
A div with role="button"
+
bump(setFake, "focus", false)} onClick={() => bump(setFake, "clicks", true)} onKeyDown={onFakeKey} style={{display: "inline-block", padding: "0.4rem 0.9rem", border: "2px solid #1d4ed8", borderRadius: "6px", cursor: "pointer"}}>Save
+
+ Reached by Tab: {fake.focus}
+ Mouse clicks: {fake.clicks}
+ Enter received: {fake.enter}
+ Space received: {fake.space}
+ Times it did its job: {fake.actions}
+
+
+
+
A real button
+
bump(setReal, "focus", false)} onClick={() => bump(setReal, "clicks", true)} onKeyDown={onRealKey}>Save
+
+ Reached by Tab: {real.focus}
+ Clicks (mouse, Enter, or Space): {real.clicks}
+ Enter received: {real.enter}
+ Space received: {real.space}
+ Times it did its job: {real.actions}
+
+
+
+
{ setFake(empty); setReal(empty); }}>Reset the counts
+
+ Press Tab to move through the page, and try Enter and Space on each one. To a screen reader, both are "button" (that is what role="button" does). But the made-up one starts with no keyboard support at all: Tab skips it, and Enter and Space do nothing. ARIA changed what it is called, not what it does. You have to add the focus and the key handling yourself, and then you have rebuilt, in more code, what the real button gave you for free. (Counts are for this demo only. Some screen readers have their own ways to activate items, so they may behave differently.)
+
+
+ );
+}
+
+export const scenarios = [
+ {label: "A button that saves", need: "Not needed", native: 'Save ', note: "A native button already has the role, focus, keyboard support, and a name."},
+ {label: "A link to another page", need: "Not needed", native: 'All recipes ', note: "An a element with an href is a link, with everything that goes with it."},
+ {label: "A required field", need: "Not needed", native: 'Email address (required) \n ', note: "The required attribute is understood by browsers and assistive technology. aria-required is not needed."},
+ {label: "Main page sections", need: "Not needed (usually)", native: '\n… \n… \n', note: "Landmark elements give you the roles. Add a name only when there is more than one of the same kind."},
+ {label: "A show and hide panel", need: "A little, or none", native: '\n Delivery details \n Free delivery over 20 dollars.
\n \n\n\nDelivery details \n…
', note: "The native details element needs no ARIA at all. If you build your own, aria-expanded carries the state."},
+ {label: "An icon-only close button", need: "A little", native: '\n … \n ', note: "The button is native. ARIA only supplies the missing name, and hides the decorative icon."},
+ {label: "A decorative icon", need: "A little", native: ' ', note: "aria-hidden removes purely decorative things from assistive technology."},
+ {label: "The current page in a menu", need: "A little", native: 'Recipes ', note: "There is no HTML for this, so aria-current says which link is the current one."},
+ {label: "A message that appears after saving", need: "Yes", native: 'Recipe saved.
', note: "Content that changes needs a live region, so it is announced without moving focus. HTML has no native way to do this."},
+ {label: "A custom tab panel", need: "Yes, a lot", native: '\n Ingredients \n Method \n
\n…
\n…
', note: "No native element does tabs. You need roles, states, arrow-key script, and a roving tabindex. Follow a tested pattern."},
+];
+
+export function AriaScenarioGuide() {
+ const [index, setIndex] = useState(0);
+ const item = scenarios[index];
+ return (
+
+
+ What are you building?
+ setIndex(Number(event.target.value))}>
+ {scenarios.map((scenario, i) => ({scenario.label} ))}
+
+
+
+
Do you need ARIA? {item.need}
+
{item.note}
+
{item.native}
+
+
+ Go down the list from top to bottom. The more custom your widget, the more ARIA it needs, and the more you must test it.
+
+
+ );
+}
+
+export const knownRoles = ["alert", "alertdialog", "application", "article", "banner", "button", "cell", "checkbox", "columnheader", "combobox", "complementary", "contentinfo", "definition", "dialog", "directory", "document", "feed", "figure", "form", "grid", "gridcell", "group", "heading", "img", "link", "list", "listbox", "listitem", "log", "main", "marquee", "math", "menu", "menubar", "menuitem", "menuitemcheckbox", "menuitemradio", "navigation", "none", "note", "option", "presentation", "progressbar", "radio", "radiogroup", "region", "row", "rowgroup", "rowheader", "scrollbar", "search", "searchbox", "separator", "slider", "spinbutton", "status", "switch", "tab", "table", "tablist", "tabpanel", "term", "textbox", "timer", "toolbar", "tooltip", "tree", "treegrid", "treeitem"];
+
+export const knownAttrs = ["activedescendant", "atomic", "autocomplete", "braillelabel", "brailleroledescription", "busy", "checked", "colcount", "colindex", "colspan", "controls", "current", "describedby", "description", "details", "disabled", "dropeffect", "errormessage", "expanded", "flowto", "grabbed", "haspopup", "hidden", "invalid", "keyshortcuts", "label", "labelledby", "level", "live", "modal", "multiline", "multiselectable", "orientation", "owns", "placeholder", "posinset", "pressed", "readonly", "relevant", "required", "roledescription", "rowcount", "rowindex", "rowspan", "selected", "setsize", "sort", "valuemax", "valuemin", "valuenow", "valuetext"];
+
+export const allowedValues = {
+ expanded: ["true", "false", "undefined"],
+ pressed: ["true", "false", "mixed", "undefined"],
+ checked: ["true", "false", "mixed", "undefined"],
+ selected: ["true", "false", "undefined"],
+ hidden: ["true", "false", "undefined"],
+ disabled: ["true", "false"],
+ required: ["true", "false"],
+ busy: ["true", "false"],
+ atomic: ["true", "false"],
+ modal: ["true", "false"],
+ multiline: ["true", "false"],
+ readonly: ["true", "false"],
+ multiselectable: ["true", "false"],
+ invalid: ["true", "false", "grammar", "spelling"],
+ live: ["off", "polite", "assertive"],
+ current: ["page", "step", "location", "date", "time", "true", "false"],
+ haspopup: ["true", "false", "menu", "listbox", "tree", "grid", "dialog"],
+};
+
+export const idRefAttrs = ["labelledby", "describedby", "controls", "owns", "details", "errormessage", "flowto", "activedescendant"];
+
+export const implicitRoles = {button: "button", nav: "navigation", main: "main", ul: "list", ol: "list", li: "listitem", img: "img", table: "table", h1: "heading", h2: "heading", h3: "heading", h4: "heading", h5: "heading", h6: "heading", aside: "complementary", footer: "contentinfo", header: "banner"};
+
+export const ariaSamples = {
+ messy: 'Save
\nOpen \nHome \nx \nHidden but focusable \nTypo role
\nMenu \n \nTypo attribute
\nHelp ',
+ fixed: 'Save \nMenu \n\nHome \n× \nSaved
\nRemember me \nHelp \nOpens the help centre.
',
+};
+
+export function AriaCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(ariaSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const focusSelector = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
+ const elements = Array.from(doc.querySelectorAll("*")).filter((el) => el.hasAttribute("role") || Array.from(el.attributes).some((attr) => attr.name.indexOf("aria-") === 0));
+ const widgetRoles = ["button", "link", "checkbox", "switch", "tab", "menuitem", "radio", "option", "slider"];
+ const nativeTags = ["button", "a", "input", "select", "textarea", "summary"];
+ const rows = elements.map((el) => {
+ const tag = el.tagName.toLowerCase();
+ const issues = [];
+ const notes = [];
+ const role = (el.getAttribute("role") || "").trim().toLowerCase().split(/\s+/)[0];
+ const focusable = el.matches(focusSelector);
+ if (role) {
+ if (knownRoles.indexOf(role) === -1) issues.push("Unknown role \"" + role + "\". Check the spelling.");
+ else {
+ const implicit = tag === "a" ? (el.hasAttribute("href") ? "link" : "") : implicitRoles[tag];
+ if (implicit === role) notes.push("Redundant: a " + tag + " already has the role " + role + ".");
+ if (widgetRoles.indexOf(role) !== -1 && nativeTags.indexOf(tag) === -1 && !focusable) issues.push("role=\"" + role + "\" on a " + tag + " that cannot be focused. ARIA adds no keyboard behaviour. Use a native element, or add tabindex and key handling.");
+ if ((role === "presentation" || role === "none") && focusable) issues.push("A focusable element must not have role=" + role + ".");
+ }
+ }
+ Array.from(el.attributes).forEach((attr) => {
+ if (attr.name.indexOf("aria-") !== 0) return;
+ const name = attr.name.slice(5);
+ const value = attr.value.trim().toLowerCase();
+ if (knownAttrs.indexOf(name) === -1) {
+ issues.push("Unknown attribute " + attr.name + ". Check the spelling.");
+ return;
+ }
+ if (allowedValues[name] && allowedValues[name].indexOf(value) === -1) issues.push(attr.name + "=\"" + attr.value + "\" is not a valid value. Allowed: " + allowedValues[name].join(", ") + ".");
+ if (idRefAttrs.indexOf(name) !== -1) {
+ attr.value.split(/\s+/).filter(Boolean).forEach((id) => { if (!doc.getElementById(id)) issues.push(attr.name + " points to the id \"" + id + "\", which does not exist."); });
+ }
+ });
+ if (el.getAttribute("aria-hidden") === "true" && (focusable || el.querySelector(focusSelector))) issues.push("aria-hidden=\"true\" on something focusable. Keyboard users can reach it, but screen reader users cannot see it.");
+ if ((el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby")) && !role && /^(div|span|p|b|i|em|strong|small|u)$/.test(tag)) notes.push("A name on a plain " + tag + " is not reliably announced. Give it a role, or put the text in the content.");
+ if (el.hasAttribute("aria-pressed") && tag !== "button" && role !== "button") issues.push("aria-pressed belongs on a button.");
+ if (el.hasAttribute("aria-checked") && tag === "input" && /^(checkbox|radio)$/.test((el.getAttribute("type") || "").toLowerCase())) notes.push("A native checkbox or radio already has a checked state. Remove aria-checked, or it can disagree with the real state.");
+ return {tag, role, issues, notes};
+ });
+ const bad = rows.reduce((sum, row) => sum + row.issues.length, 0);
+ const choose = (value) => { setWhich(value); setHtml(ariaSamples[value]); };
+ return (
+
+
+ Which HTML do you want to check?
+ choose("messy")} /> Messy ARIA
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} elements use ARIA, with {bad} problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + (row.role ? " role=" + row.role : "") + ">"}{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+
+ A teaching checker with a shortened list of roles and attributes. It cannot know whether your ARIA is the right ARIA, or whether a widget really works. Use a full checker (such as your browser's accessibility panel, axe, or Lighthouse) and test with real assistive technology.
+
+
+ );
+}
+
+export function AriaChecker() {
+ return {() => } ;
+}
+
+So far in this section you have used plain HTML to make pages accessible: real headings, real buttons, real labels, real tables. That is the best foundation, and it will carry you a long way. But sometimes HTML alone is not enough. A tab panel, a live "Saved!" message, an icon-only button: these need a little extra help to tell assistive technology what is going on.
+
+That help is **ARIA.** It is powerful, and easy to misuse. This lesson explains what ARIA is, what it can and **cannot** do, and the few rules that keep you out of trouble. The next lessons look at the main parts one by one.
+
+
+
+
+## What You Will Learn
+
+- What **ARIA** stands for, and what it does
+- The three kinds of ARIA: **roles, properties,** and **states**
+- The **five rules of ARIA**
+- Why **native HTML comes first**
+- What ARIA **cannot** do
+- **When** you really need ARIA
+- How to **check** your ARIA
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+Save
+
+
+
+ …
+
+
+
+
+ Delivery details
+
+…
+
+
+Recipe saved.
+
+
+Save
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Try the lab first with both boxes unticked, then tick them one at a time. You will see how much work a real `button` does that a `div` with ARIA does not.
+
+## What Is ARIA?
+
+**ARIA** stands for **Accessible Rich Internet Applications.** (You will also see **WAI-ARIA,** from the W3C's Web Accessibility Initiative.) It is a set of **attributes** you add to HTML elements to give assistive technology more information:
+
+- **Roles** say **what** something is (`role="tablist"`).
+- **Properties** describe **it** (`aria-label="Close"`, `aria-controls="menu"`).
+- **States** say **what it is doing right now** (`aria-expanded="true"`, `aria-pressed="false"`).
+
+Think of ARIA as **a note stuck on the accessibility tree** (see [Introduction to Accessibility](./introduction-to-a11y)). It changes what a screen reader **hears,** and nothing else.
+
+### What ARIA Does Not Do
+
+- **It does not add behaviour.** `role="button"` makes something sound like a button. It does not make it focusable, or give it Enter and Space support, as the lab showed.
+- **It does not change how things look.** A `div` with `role="heading"` is not bigger or bolder.
+- **It does not fix the focus order** or the keyboard.
+- **It does not work magic on its own.** Support varies between browsers and screen readers, and a wrong role can make things **worse** than none.
+
+:::warning
+You may hear **"No ARIA is better than bad ARIA."** Large surveys of home pages (such as the WebAIM Million) have repeatedly found that pages **with** ARIA tend to have **more** detected accessibility errors than pages without it. That does not mean ARIA is bad. It means ARIA is **easy to get wrong,** and a wrong role is worse than a missing one. Use it with care, and test.
+:::
+
+## The Five Rules of ARIA
+
+The W3C's guide to using ARIA boils everything down to five rules:
+
+1. **If you can use a native HTML element or attribute with the meaning and behaviour you need, use it,** instead of adding ARIA. (Use `button`, not `div role="button"`.)
+2. **Do not change native semantics,** unless you really have to. (Do not write ``. Put a `button` inside the heading.)
+3. **All interactive ARIA controls must be usable with the keyboard.** If you make a `role="button"`, it must be focusable and respond to Enter and Space.
+4. **Do not use `role="presentation"` or `aria-hidden="true"` on a focusable element.** It would be reachable by keyboard, but invisible to a screen reader.
+5. **All interactive elements must have an accessible name.** See [Accessible Names](./accessible-names).
+
+If you remember only one thing, remember rule 1.
+
+## Native HTML First
+
+Almost every ARIA role you meet has a **native** equivalent that is better:
+
+| Instead of this | Use this |
+| :-- | :-- |
+| `div role="button"` | `button` |
+| `div role="link"` | `a href` |
+| `div role="navigation"` | `nav` |
+| `div role="main"` | `main` |
+| `div role="heading" aria-level="2"` | `h2` |
+| `div role="list"` and `listitem` | `ul` and `li` |
+| `div role="checkbox"` | `input type="checkbox"` |
+| `div role="dialog"` | `dialog` |
+| `aria-required="true"` | `required` |
+| `aria-disabled` on a form field | `disabled` |
+
+Native elements come with **focus, keyboard support, a role, a name, and a state,** all tested by browser makers for years. ARIA is for the gaps where HTML has **no** native answer.
+
+## Roles, Properties, and States
+
+Here is a map of the ARIA you will meet. Each group gets its own lesson soon.
+
+| Kind | What it does | Examples |
+| :-- | :-- | :-- |
+| **Roles** | Say what something is | `tablist`, `dialog`, `status`, `alert`, `search` |
+| **Properties** | Describe and connect things | `aria-label`, `aria-labelledby`, `aria-describedby`, `aria-controls` |
+| **States** | Change as people use the page | `aria-expanded`, `aria-pressed`, `aria-checked`, `aria-selected`, `aria-hidden`, `aria-current` |
+| **Live regions** | Announce changes | `aria-live`, `role="status"`, `aria-atomic` |
+
+### How the Syntax Works
+
+- Every ARIA attribute begins with **`aria-`,** and the role is the plain **`role`** attribute.
+- **True and false are words:** `aria-expanded="true"` and `aria-expanded="false"`. Write them as strings, with the quotes.
+- Some values come from a **fixed list:** `aria-live="polite"`, `aria-current="page"`, `aria-pressed="mixed"`.
+- Some values are the **`id` of another element:** `aria-labelledby="title"`, `aria-controls="menu"`. The id must exist.
+- Some are **plain text:** `aria-label="Close"`. Write it in the page's language.
+- **States must be kept up to date with script.** If a panel opens and `aria-expanded` still says `false`, ARIA is now lying to people.
+- A **missing** attribute and a `false` value can mean different things for some attributes (for example `aria-pressed`: not a toggle at all, or a toggle that is off). Follow the rules for each attribute.
+
+## Do You Need ARIA?
+
+Use the guide below on things you are building. Notice how many answers are "Not needed."
+
+
+
+
+```html title="index.html"
+
+Email address (required)
+
+
+
+
+ …
+
+
+
+Recipes
+
+
+Recipe saved.
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+## Where to Learn the Patterns
+
+When you must build a custom widget (tabs, a menu, a combobox, an accordion), do **not** invent the ARIA yourself. Follow a tested pattern, such as those in the **WAI-ARIA Authoring Practices Guide (APG).** It lists the roles, states, and keyboard keys for each widget, with examples.
+
+Even a good pattern needs **testing,** because screen reader support differs. A pattern that works well in one combination (say, a screen reader and browser on Windows) may behave differently on a phone. Test with the combinations your audience really uses.
+
+## Check Your ARIA
+
+The checker below finds common mistakes: unknown roles and attributes, bad values, references to ids that do not exist, `aria-hidden` on focusable things, made-up buttons that cannot be focused, redundant roles, and `aria-label` on plain `div` or `span` elements.
+
+
+<>
+
+>
+
+
+On a real site, also:
+
+1. **Search your code for `role=` and `aria-`.** For each one, ask: could a native element do this?
+2. **Use the browser's Accessibility panel** to check the role, name, and state of each widget.
+3. **Run an automated checker** (such as axe or Lighthouse) to catch invalid ARIA.
+4. **Use the keyboard** on every ARIA widget. Can you reach it, and operate it?
+5. **Listen with a screen reader.** Do the name, role, and state sound right, and do they update?
+
+## Good Habits
+
+1. **Reach for native HTML first,** every time.
+2. **Add ARIA only to fill a real gap.**
+3. **Keep ARIA states up to date** with script.
+4. **Make every ARIA control work with a keyboard.**
+5. **Never hide a focusable element** with `aria-hidden`.
+6. **Follow a tested pattern** for custom widgets.
+7. **Check that every id you reference exists.**
+8. **Write names in the page's language,** and keep them short.
+9. **Test with real assistive technology,** and review ARIA in code review.
+
+## Accessibility Checklist
+
+- [ ] No ARIA is used where a native element or attribute would do.
+- [ ] Every ARIA role is valid, and suits the element.
+- [ ] Every custom widget can be focused and used with a keyboard.
+- [ ] No focusable element has `aria-hidden="true"` or `role="presentation"`.
+- [ ] Every interactive element has an accessible name.
+- [ ] ARIA states (`aria-expanded`, `aria-pressed`, and so on) change when the widget changes.
+- [ ] Every id used in `aria-labelledby`, `aria-describedby`, and `aria-controls` exists.
+- [ ] ARIA values are valid, and spelled correctly.
+- [ ] Custom widgets follow a known pattern, and were tested with a screen reader.
+- [ ] The ARIA has been checked with an automated tool, and by a person.
+
+## Security and Privacy Notes
+
+- **Do not put private data in ARIA attributes.** Values such as `aria-label` can be read aloud, read by browser extensions, and seen in the page source. Do not include passwords, personal identifiers, or hidden information.
+- **Do not use ARIA to mislead.** A name that does not match what a control really does (for example "Cancel" on a button that confirms a purchase) is deceptive, and harms people who rely on it.
+- **Be careful what you announce.** A live region that reads out sensitive information (a one-time code, an account balance, a typed password) could be overheard by someone nearby.
+- **Do not use `aria-hidden` to hide important warnings,** security messages, or legal notices from screen reader users.
+- **Do not detect or track assistive technology use** by looking for ARIA behaviour or settings. It can reveal a disability.
+- **Test that ARIA cannot be abused** if you render content from users. Never insert user-supplied text into `role` or `aria-*` attributes without cleaning it first.
+
+## Global Audience Notes
+
+- **Translate ARIA text.** Values such as `aria-label`, `aria-description`, and `aria-roledescription` are part of your content. Include them in your translation process, and test with the tools your audience uses.
+- **Set the page language** with `lang`, so screen readers pick the right voice for the names ARIA supplies.
+- **Avoid `aria-roledescription`** unless you have a clear need. A custom description is read in your words, and may be wrong in other languages, or confuse people who know the usual role names.
+- **Right-to-left languages:** arrow-key patterns (tabs, menus, sliders) should follow the reading direction. Test them in a right-to-left layout.
+- **Assistive technology differs by region and device.** Many people use a screen reader on a phone, and some use older or free tools. Test on both a desktop and a mobile screen reader, and keep your markup simple.
+- **Simple wording** in ARIA labels translates best, and is easier for people reading in a second language.
+
+## Common Mistakes to Avoid
+
+1. **`div` or `span` with `role="button"`** and no keyboard support.
+2. **Putting ARIA on everything,** "just in case."
+3. **Adding `role="button"` to a `button`,** or `role="navigation"` to a `nav`.
+4. **`aria-hidden="true"` on a focusable element.**
+5. **States that never change,** such as `aria-expanded` stuck on `false`.
+6. **Pointing to ids that do not exist.**
+7. **Misspelled roles and attributes** (`aria-lable`).
+8. **Invalid values** (`aria-expanded="yes"`).
+9. **Using `aria-label` on a plain `div` or `span`.**
+10. **Using ARIA to repair bad HTML** instead of fixing the HTML.
+11. **Inventing your own widget patterns.**
+12. **Never testing with a screen reader.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the behaviour lab with both boxes unticked, and press Tab, Enter, and Space. Describe what happens.
+2. Tick the boxes one at a time, and note how much extra work the made-up button needs.
+3. Go through every item in the "Do I need ARIA?" guide, and say which are native.
+4. Run the checker on the messy page, fix every problem, and run it again.
+5. Find three places in a project where you use `role=` or `aria-`. Could a native element do the job?
+6. Replace a `div role="button"` with a real `button`.
+7. Open the Accessibility panel, and look at the role, name, and state of a button, a link, and a checkbox.
+8. Add `aria-current="page"` to the current link in a menu.
+9. Make a "Saved" message with `role="status"`, and trigger it from a button.
+10. Bonus: read the APG pattern for tabs, and list every role, state, and key it needs.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What does ARIA stand for, and what does it do?
+
+Accessible Rich Internet Applications. It adds roles, properties, and states that tell assistive technology more about an element.
+
+
+
+
+2. What is the first rule of ARIA?
+
+If a native HTML element or attribute can do the job, use it, and do not add ARIA.
+
+
+
+
+3. Does `role="button"` make a `div` work like a button?
+
+No. It only changes what assistive technology announces. You still have to add focus, and Enter and Space support, yourself.
+
+
+
+
+4. What are the three kinds of ARIA attributes?
+
+Roles (what it is), properties (what it is like, and what it relates to), and states (what it is doing right now).
+
+
+
+
+5. Why must you never put `aria-hidden="true"` on a focusable element?
+
+Keyboard users could still reach it, but screen reader users would not know it exists.
+
+
+
+
+6. When do you need ARIA?
+
+When HTML has no native answer: custom widgets such as tabs, live messages, extra states such as `aria-expanded`, and missing names for icon-only controls.
+
+
+
+## Key Takeaways
+
+- **ARIA adds meaning, not behaviour.** It changes what assistive technology hears, and nothing else.
+- **Native HTML first.** It is shorter, tested, and more reliable.
+- **Roles, properties, and states** are the three parts of ARIA.
+- **Follow the five rules,** especially: keyboard support, no hiding focusable things, and an accessible name for everything interactive.
+- **Keep states up to date,** and make sure referenced ids exist.
+- **Copy tested patterns** for custom widgets, and do not invent your own.
+- **Test with a keyboard and a screen reader.** Bad ARIA is worse than none.
+
+Next, learn what roles do, and which ones are worth knowing, in [ARIA Roles](./aria-roles).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/aria-label.mdx b/tutorials/html/13-accessibility/aria-label.mdx
index e69de29bb..03aca9d39 100644
--- a/tutorials/html/13-accessibility/aria-label.mdx
+++ b/tutorials/html/13-accessibility/aria-label.mdx
@@ -0,0 +1,542 @@
+---
+id: html-aria-label
+title: "Aria Label"
+sidebar_label: "Aria Label"
+sidebar_position: 17
+slug: /html/accessibility/aria-label
+description: "Learn when and how to use aria-label: naming icon-only buttons, search fields, repeated landmarks, and SVG images; when not to use it (elements that cannot be named, visible text that already works); the Label in Name rule; and how to write short, unique, translatable labels. Includes a name override lab, a label writing practice, and a live aria-label audit."
+keywords:
+ - aria-label
+ - accessible name
+ - icon button label
+ - label in name
+ - WCAG 2.5.3
+ - aria-label vs aria-labelledby
+ - aria-label on div
+ - landmark label
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, aria, aria-label, accessible-name, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const roleWords = /\b(button|link|icon|image|picture|navigation|field)\b/i;
+
+export const labelScenarios = [
+ {id: "button", title: "A button with visible text", visible: "Search", role: "button", supports: true, needs: false, start: "Search recipes"},
+ {id: "icon", title: "An icon-only button", visible: "", role: "button", supports: true, needs: true, start: "Close"},
+ {id: "link", title: "A link that says Read more", visible: "Read more", role: "link", supports: true, needs: false, start: "Read more about rye bread"},
+ {id: "nav", title: "A second navigation area", visible: "", role: "navigation", supports: true, needs: true, start: "Footer links"},
+ {id: "input", title: "A search field with no visible label", visible: "", role: "search field", supports: true, needs: true, start: "Search recipes"},
+ {id: "svg", title: "A chart drawn with SVG", visible: "", role: "image", supports: true, needs: true, start: "Bar chart of weekly sales"},
+ {id: "div", title: "A plain div", visible: "Heads up!", role: "generic", supports: false, needs: false, start: "Warning"},
+];
+
+export function NameOverrideLab() {
+ const [index, setIndex] = useState(0);
+ const [label, setLabel] = useState(labelScenarios[0].start);
+ const scn = labelScenarios[index];
+ const text = label.trim();
+ const attr = text ? text : undefined;
+ const stop = (event) => event.preventDefault();
+ let heard = "";
+ if (!scn.supports) heard = "(no reliable name)";
+ else if (text) heard = text;
+ else heard = scn.visible || "(no name)";
+ const notes = [];
+ if (!scn.supports) notes.push("A plain div has no role, so a name is not reliably announced. Many screen readers ignore aria-label here. Use visible text, or give the element a proper role.");
+ if (scn.supports && scn.needs && !text) notes.push("FAIL: there is no visible text and no aria-label, so this has no accessible name.");
+ if (scn.supports && scn.visible && text && text.toLowerCase().indexOf(scn.visible.toLowerCase()) === -1) notes.push("WARNING: the visible text \"" + scn.visible + "\" is not in the name \"" + text + "\". Voice control users who say \"click " + scn.visible + "\" will not match it (WCAG 2.5.3).");
+ if (scn.supports && scn.visible && text && text.toLowerCase() === scn.visible.toLowerCase()) notes.push("The aria-label only repeats the visible text. You can remove it.");
+ if (scn.supports && text && roleWords.test(text)) notes.push("The label contains a role word (" + text.match(roleWords)[0] + "). Screen readers already say the role, so it would be said twice.");
+ if (text.length > 80) notes.push("This is long. A label should be a short name, not a sentence.");
+ if (notes.length === 0) notes.push("This works: a short name, with no repeated role, and any visible text is included.");
+ return (
+
+
+ Pick an element
+ { const next = Number(event.target.value); setIndex(next); setLabel(labelScenarios[next].start); }}>
+ {labelScenarios.map((item, i) => ({item.title} ))}
+
+
+
+ aria-label (leave empty to remove it)
+ setLabel(event.target.value)} style={{width: "100%"}} />
+
+
+
What people see:
+
+ {scn.id === "button" ?
Search : null}
+ {scn.id === "icon" ?
× : null}
+ {scn.id === "link" ?
Read more : null}
+ {scn.id === "nav" ?
Privacy {" | "}Terms : null}
+ {scn.id === "input" ?
: null}
+ {scn.id === "svg" ?
: null}
+ {scn.id === "div" ?
Heads up!
: null}
+
+
What a screen reader may say: {heard + ", " + scn.role}
+
+ {notes.map((note, i) => ({note} ))}
+
+
+
+ Notice that aria-label replaces the name from the content: sighted people see "Search" while a screen reader hears your label. That is useful when there is no visible text, and risky when there is, because the two can drift apart. The spoken line is a simplified example, and different tools word it differently.
+
+
+ );
+}
+
+export const writerScenarios = [
+ {prompt: "A dialog has a corner button that shows only an X. It closes the dialog.", role: "button", sample: "Close", check: (t) => (/(close|dismiss)/i.test(t) ? [] : ["Name the action: what does this button do?"])},
+ {prompt: "The header has a search field with a magnifying glass in it, and no visible label.", role: "search field", sample: "Search recipes", check: (t) => (/search/i.test(t) ? [] : ["Say what the field is for, such as searching."])},
+ {prompt: "The page has two navigation areas. The first is already named Main. Name the one in the footer.", role: "navigation", sample: "Footer links", check: (t) => [].concat(/^main/i.test(t) ? ["This is the same as the first nav. Each one needs a different name."] : []).concat(/(footer|legal|more|links|secondary|site)/i.test(t) ? [] : ["Say what is different about this one (for example, that it is in the footer)."])},
+ {prompt: "A heart icon button saves a recipe to your favourites. It is a toggle, and aria-pressed is already set for you.", role: "toggle button", sample: "Favourite", check: (t) => [].concat(/favou?rite/i.test(t) ? [] : ["Name what is being toggled."]).concat(/\b(add|remove|un|saved)/i.test(t) ? ["Do not put the state in the name. aria-pressed already says on or off."] : [])},
+ {prompt: "There is a trash icon in the table row for Rye bread. It deletes that recipe.", role: "button", sample: "Delete rye bread recipe", check: (t) => [].concat(/(delete|remove)/i.test(t) ? [] : ["Name the action."]).concat(/rye/i.test(t) ? [] : ["Add which item it affects, so every row's button is different."])},
+];
+
+export function LabelWriter() {
+ const [index, setIndex] = useState(0);
+ const [text, setText] = useState("");
+ const [show, setShow] = useState(false);
+ const scn = writerScenarios[index];
+ const value = text.trim();
+ const notes = [];
+ let level = "ok";
+ if (value === "") {
+ level = "fail";
+ notes.push("Write a label. With no text and no aria-label, the control has no name.");
+ } else {
+ scn.check(value).forEach((note) => notes.push(note));
+ if (roleWords.test(value)) notes.push("Remove the role word (" + value.match(roleWords)[0] + "). The screen reader says it already.");
+ if (value.length > 50) notes.push("Shorter is better. Aim for two to four words.");
+ if (notes.length > 0) level = "warn";
+ if (notes.length === 0) notes.push("Good. It is short, names the action or purpose, and does not repeat the role.");
+ }
+ return (
+
+
+ Pick a situation
+ { setIndex(Number(event.target.value)); setText(""); setShow(false); }}>
+ {writerScenarios.map((item, i) => ({"Situation " + (i + 1)} ))}
+
+
+
The situation: {scn.prompt}
+
+ Your aria-label
+ setText(event.target.value)} style={{width: "100%"}} />
+
+
A screen reader may say: {(value || "(no name)") + ", " + scn.role}
+
Feedback: {level === "ok" ? "Good" : level === "warn" ? "Could be better" : "Needs work"}.
+
+ {notes.map((note, i) => ({note} ))}
+
+
setShow(!show)}>{show ? "Hide the sample answer" : "Show a sample answer"}
+ {show ?
Sample: {'aria-label="' + scn.sample + '"'}
: null}
+
+ These are simple rules of thumb. Other good answers exist, and the best label depends on the page around it.
+
+
+ );
+}
+
+export const labelSamples = {
+ messy: 'Search \n× \nRead more \n! \nHome \nPrivacy \nSave \nSearch \n Chart
',
+ fixed: 'Search \n× \nRead more \n⚠ \nHome \nPrivacy \nSearch recipes ',
+};
+
+export function labelTextOf(node) {
+ if (node.nodeType === 3) return node.textContent;
+ if (node.nodeType !== 1) return "";
+ if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) return "";
+ if (node.tagName.toLowerCase() === "img") return node.getAttribute("alt") || "";
+ let out = "";
+ node.childNodes.forEach((child) => { out += " " + labelTextOf(child) + " "; });
+ return out;
+}
+
+export function LabelAuditInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(labelSamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const elements = Array.from(doc.querySelectorAll("[aria-label]"));
+ const counts = {};
+ elements.forEach((el) => {
+ const key = el.tagName.toLowerCase() + (el.getAttribute("role") || "") + ":" + el.getAttribute("aria-label").trim().toLowerCase();
+ counts[key] = (counts[key] || 0) + 1;
+ });
+ const rows = elements.map((el) => {
+ const tag = el.tagName.toLowerCase();
+ const role = el.getAttribute("role") || "";
+ const label = el.getAttribute("aria-label").trim();
+ const issues = [];
+ const notes = [];
+ const visible = labelTextOf(el).replace(/\s+/g, " ").trim();
+ const textual = tag === "button" || tag === "a" || role === "button" || role === "link";
+ if (label === "") issues.push("An empty aria-label does nothing.");
+ if (label !== "" && !role && /^(div|span|p|b|i|em|strong|small|u)$/.test(tag)) issues.push("A name on a plain " + tag + " (no role) is not reliably announced. Add a role, or use visible text.");
+ if (label !== "" && roleWords.test(label)) notes.push("The label contains a role word (" + label.match(roleWords)[0] + "). It would be said twice.");
+ if (label !== "" && textual && visible && label.toLowerCase().indexOf(visible.toLowerCase()) === -1) issues.push("The visible text \"" + visible + "\" is not in the name (WCAG 2.5.3).");
+ if (label !== "" && textual && visible && label.toLowerCase() === visible.toLowerCase()) notes.push("It repeats the visible text. Remove the aria-label.");
+ if (label !== "" && tag === "input" && el.getAttribute("id")) {
+ const found = Array.from(doc.querySelectorAll("label")).find((item) => item.getAttribute("for") === el.getAttribute("id"));
+ if (found) {
+ const shown = found.textContent.trim();
+ if (label.toLowerCase().indexOf(shown.toLowerCase()) === -1) issues.push("This overrides the visible label \"" + shown + "\" with a different name.");
+ else notes.push("A visible label exists already. The aria-label is not needed.");
+ }
+ }
+ if (label.length > 80) notes.push("Long. A label should be a short name.");
+ const key = tag + role + ":" + label.toLowerCase();
+ if (label !== "" && counts[key] > 1) issues.push("The same label is used on " + counts[key] + " elements. Make each one different.");
+ return {tag, role, label, issues, notes};
+ });
+ const bad = rows.reduce((sum, row) => sum + row.issues.length, 0);
+ const choose = (value) => { setWhich(value); setHtml(labelSamples[value]); };
+ return (
+
+
+ Which HTML do you want to check?
+ choose("messy")} /> Messy labels
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} elements use aria-label, with {bad} problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + (row.role ? " role=" + row.role : "") + ">"} {row.label ? {'"' + row.label + '"'} : empty }{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+
+ This reads the HTML only. It cannot judge whether a label is clear, or whether another way of naming (a visible label, or visible text) would be better. Treat it as a first pass.
+
+
+ );
+}
+
+export function LabelAudit() {
+ return {() => } ;
+}
+
+Some things on a page have **no visible text** that could name them: a button with only an icon, a search field with a magnifying glass, a second navigation area, a chart drawn with SVG. Assistive technology still needs a name for each one. `aria-label` lets you **write that name directly** in an attribute.
+
+It is the simplest ARIA attribute, and the one most often used wrongly. In this lesson you will learn **exactly when** it helps, and when you should reach for something better.
+
+
+
+
+## What You Will Learn
+
+- What **`aria-label`** does, and what it **overrides**
+- **When** to use it, and when **not** to
+- Which elements it works on, and which it does **not**
+- The **Label in Name** rule (WCAG 2.5.3)
+- How to write **short, unique, translatable** labels
+- How it compares with **visible text, `aria-labelledby`,** and **`title`**
+- How to **audit** your labels
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+
+ …
+
+
+
+
+
+
+…
+…
+
+
+…
+
+
+Save recipe
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Try every example in the lab. Empty the label, and watch the icon button lose its name. Then change the "Search" button to say something different, and read the warning.
+
+## What `aria-label` Does
+
+`aria-label` gives an element an **accessible name** as a plain **text string.** It is **invisible:** sighted people never see it. It is used by screen readers, voice control, and other assistive tools (see [Accessible Names](./accessible-names) for the full picture).
+
+Where it sits in the naming order matters:
+
+1. `aria-labelledby` (if present) wins.
+2. **`aria-label`** comes next.
+3. After that: native labels, the element's own content, `title`, and placeholders.
+
+This means **`aria-label` replaces** the name that would have come from the content. A button with the text "Search" and `aria-label="Find recipes"` is **called "Find recipes"** by a screen reader, even though everyone else sees "Search." That power is also the risk.
+
+## When to Use `aria-label`
+
+Use it when there is **no visible text** to name the thing, and adding visible text would not make sense:
+
+- **Icon-only buttons and links:** "Close," "Search," "Open menu."
+- **A search field** whose purpose is clear from a nearby button or icon.
+- **Repeated landmarks:** two `nav` elements, or two `aside` elements.
+- **Regions, groups, and toolbars** with no visible heading: `role="group"`, `role="toolbar"`.
+- **SVG images and emoji groups** with `role="img"`.
+- **Custom widgets** whose name has no visible source.
+- **Making a repeated link unique,** when you cannot change the text ("Read more" becomes "Read more about rye bread"), as long as the visible text stays at the start of the label.
+
+## When Not to Use `aria-label`
+
+Always ask first: **is there visible text I can use instead?** Visible text is better for everyone: it can be read, searched, translated, and zoomed.
+
+| Instead of this | Use this |
+| :-- | :-- |
+| `aria-label` on a field that needs a clear label | A visible `label` element |
+| `aria-label` repeating the button's visible text | Nothing. Remove it. |
+| `aria-label` when a visible heading names a section | `aria-labelledby` pointing at the heading |
+| `aria-label` to describe a long hint | `aria-describedby` |
+| `aria-label` on a plain `div` or `span` | A real element, a role, or visible text |
+| `aria-label` on a `p` or text to "add a tooltip" | `title` is no better. Use visible text, or a proper tooltip pattern. |
+| `aria-label` on an `iframe` | The `title` attribute |
+
+The next lesson, [aria-labelledby](./aria-labelledby), covers pointing a name at text that is already on the page.
+
+## Elements That Can and Cannot Be Named
+
+`aria-label` should only be used on elements that **have a role that accepts a name.**
+
+| Works well | Not reliable |
+| :-- | :-- |
+| `button`, `a`, `input`, `select`, `textarea` | A plain `div` or `span` (no role) |
+| Landmarks: `nav`, `aside`, `main`, `section`, `form` | `p`, `strong`, `em`, and other text elements |
+| Elements with a role: `role="img"`, `role="group"`, `role="dialog"`, `role="tablist"`, `role="tab"` | `code`, `b`, `i`, `u`, `small` |
+| `table`, `figure`, `fieldset` | Elements with `role="presentation"` or `role="none"` |
+
+In the current ARIA rules, a name is **prohibited** on roles such as `generic` (a plain `div` or `span`), `paragraph`, `presentation`, `strong`, `emphasis`, `code`, and a few more. Some screen readers still read them, others ignore them, and the results change between versions, so **do not depend on it.** If you want a `div` to have a name, give it a proper role, or restructure the HTML.
+
+## Label in Name: Match What People See
+
+WCAG success criterion **2.5.3 (Label in Name)** says: if a control has **visible text,** the accessible name must **contain** that text, ideally at the start.
+
+Why? **Voice control** users say what they see. If the screen shows "Search," they say "click Search." If your hidden name is "Find recipes," that command fails. People who use a screen reader together with sighted colleagues, or who read the screen and listen at the same time, hear something that does not match what is shown.
+
+```html
+
+Search
+
+
+Search
+
+
+Read more
+```
+
+## Writing a Good Label
+
+1. **Name the action or the purpose,** not the picture: "Close," not "X" or "Cross icon."
+2. **Keep it short.** Two to four words is usually plenty. Put long help in a description.
+3. **Do not repeat the role.** "Close," not "Close button." "Main," not "Main navigation."
+4. **Make it unique** among things of the same kind. Two buttons that both say "Delete" need to say what they delete: "Delete rye bread recipe."
+5. **Do not put the state in the label.** If a toggle is "Favourite," use `aria-pressed` for on and off, instead of changing the label to "Unfavourite."
+6. **Include the visible text,** and start with it.
+7. **Write it in the page language,** in normal case, with no tricks like all capitals or symbols.
+8. **Avoid abbreviations** and jargon that a voice reading aloud may mispronounce.
+
+### Practice Writing Labels
+
+Try writing labels for five situations. You get instant feedback, and you can reveal a sample answer.
+
+
+<>
+
+>
+
+
+## `aria-label` or Something Else?
+
+| Method | Good for | Watch out for |
+| :-- | :-- | :-- |
+| **Visible text** (content or `label`) | Almost everything | Needs room in the design |
+| **Visually hidden text** (a screen-reader-only class) | Icon buttons, where you want the text to translate and be found like normal content | Needs a CSS class, and extra markup |
+| **`aria-label`** | Short names for things with no visible text | Invisible, ignored on plain `div` and `span`, may be skipped by some translation tools |
+| **`aria-labelledby`** | Naming something from text already on the page | The id must exist |
+| **`title`** | A tooltip for mouse users, as a last-resort name | Not reliably announced, not shown to keyboard or touch users |
+
+Visible text, or visually hidden text, is often the safest choice for **multilingual** sites, because translation tools reliably translate it. See the global notes below.
+
+## Audit Your Labels
+
+The audit below reads every element with an `aria-label` in a snippet of HTML, and flags empty labels, labels on plain `div` or `span`, role words in labels, labels that do not contain the visible text, labels that only repeat the visible text, labels that override a visible form label, and duplicates.
+
+
+<>
+
+>
+
+
+On a real site, you can also:
+
+1. **Search your code for `aria-label`,** and for each one ask: could visible text do this?
+2. **Open the Accessibility panel** in your browser's DevTools, and read each name.
+3. **Use a screen reader** on your icon buttons and landmarks. Do they make sense in a list of links, buttons, and landmarks?
+4. **Test voice control:** say "click" and the visible text of each control.
+5. **Run a checker** (axe, Lighthouse) for missing names and mismatches.
+
+## Good Habits
+
+1. **Use visible text first,** and `aria-label` only when there is none.
+2. **Keep labels short, unique, and action-focused.**
+3. **Start the label with the visible text** when there is any.
+4. **Never repeat the role** in the label.
+5. **Use `aria-label` only on elements that can have a name.**
+6. **Hide decorative icons** with `aria-hidden="true"` when you name the button.
+7. **Include labels in your translation process.**
+8. **Check names** in the Accessibility panel, and with voice control.
+
+## Accessibility Checklist
+
+- [ ] Every control with no visible text has a name (`aria-label` or hidden text).
+- [ ] No `aria-label` repeats visible text, or contradicts it.
+- [ ] Visible text is included at the start of the accessible name (WCAG 2.5.3).
+- [ ] Repeated landmarks have different names, and no role words.
+- [ ] `aria-label` is not used on plain `div` or `span` elements, or on text elements.
+- [ ] Labels are short, in the page language, and unique where needed.
+- [ ] Labels do not carry state (use `aria-pressed` and similar).
+- [ ] Decorative icons inside named controls are hidden.
+- [ ] Labels are part of the translation process.
+- [ ] Names were checked in the Accessibility panel and with real assistive technology.
+
+## Security and Privacy Notes
+
+- **`aria-label` is public.** Anyone can read it in the page source, and assistive technology reads it aloud. Do not put personal data, account numbers, tokens, or internal notes in it.
+- **Do not use it to mislead.** A label that says "Cancel" on a button that confirms a payment, or a hidden name that differs from the visible text in a harmful way, deceives people who cannot see the screen. It is also a form of clickjacking-style trickery.
+- **Be careful with names that include user data.** "Delete message from Sam" in a shared workspace may be read aloud to people nearby. Weigh it up for sensitive content.
+- **Escape user-generated text** before it goes into an attribute, to prevent script injection. Never place raw input inside `aria-label`.
+- **Do not use labels to detect assistive technology** (for example, by checking whether a hidden label is read). That could reveal a disability.
+- **Hidden text is not secret.** Do not use `aria-label` to hide content from sighted users that you would not want them to know about.
+
+## Global Audience Notes
+
+- **Translate `aria-label` with the rest of your page.** It is content, not code. Make sure your translation workflow includes attributes. Some automatic page-translation tools may not translate them, so test with the tools your readers use, or prefer visible or visually hidden text where translation matters.
+- **Keep labels short.** Translated words are often longer, and short, plain words translate better (and are easier for people reading in a second language).
+- **Set the page language** with `lang`, so the screen reader voice speaks your labels correctly. If a label contains a name or word from another language, wrap that in visible text with its own `lang` instead.
+- **Voice control users speak in their own language.** Another reason to include the visible text in the name.
+- **Avoid symbols and slang** in labels, such as "X," "hamburger," or "thumbs up." They may be read oddly, or not translate.
+- **Right-to-left languages:** write the label in the direction of its language, and test mixed text with numbers or Latin words, which can appear in an unexpected order.
+- **Do not repeat state words** such as "expanded" in the label. Screen readers say them in the user's own language.
+
+## Common Mistakes to Avoid
+
+1. **Icon-only buttons with no name at all.**
+2. **`aria-label` that does not contain the visible text.**
+3. **`aria-label` repeating the visible text.**
+4. **Role words in the label** ("Close button," "Navigation").
+5. **`aria-label` on a plain `div` or `span`** with no role.
+6. **The same label on several elements** ("Navigation," "Navigation").
+7. **An empty `aria-label=""`.**
+8. **Overriding a visible label** with a different hidden one.
+9. **Long, sentence-style labels.**
+10. **Changing the label to show state** ("Mute," then "Unmute," with `aria-pressed`).
+11. **Forgetting to translate labels.**
+12. **Never checking names in the Accessibility panel.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the name override lab for each element, and write down the name a screen reader would hear.
+2. Give the "Search" button a label that does not contain "Search," and read the warning. Then fix it.
+3. In the writing practice, write your own label for each situation before you look at the sample.
+4. Run the audit on the messy HTML, fix every problem, and run it again.
+5. Find three icon-only buttons in a project, and give each a good label.
+6. Give two `nav` elements different names, and check them in the Accessibility panel.
+7. Replace one `aria-label` with visually hidden text, and compare how it behaves with a page-translation tool.
+8. Use `aria-label` to make three "Read more" links unique, keeping "Read more" at the start.
+9. Turn on voice control (if you have it), and say "click" and a visible label.
+10. Bonus: search a site you like for icon buttons, and open DevTools to see which have good names.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What does `aria-label` do?
+
+It gives an element an invisible, text accessible name, which replaces the name that would come from its content.
+
+
+
+
+2. When should you use it?
+
+When there is no visible text to name a control, such as an icon-only button, a search field, a repeated landmark, or an SVG image.
+
+
+
+
+3. Why is `aria-label` on a plain `div` a problem?
+
+A plain `div` has no role that accepts a name, so the label is not reliably announced. Use a real element, give it a role, or use visible text.
+
+
+
+
+4. What does "Label in Name" require?
+
+The accessible name must contain the visible text of the control, ideally at the start, so voice control users can say what they see.
+
+
+
+
+5. Why not write "Close button" as a label?
+
+Screen readers already say "button," so it would be read twice. Write "Close."
+
+
+
+
+6. What is often safer than `aria-label` on a multilingual site?
+
+Visible text, or visually hidden text, because translation tools reliably translate it, and some may not translate attributes.
+
+
+
+## Key Takeaways
+
+- **`aria-label` is an invisible name,** written as text. It replaces the name from the content.
+- **Use it only when there is no visible text,** and prefer visible text, a `label`, or `aria-labelledby` when you can.
+- **It works on named roles,** such as buttons, links, inputs, landmarks, and `role="img"`. It is **not reliable** on plain `div` and `span`.
+- **Include the visible text** in the name (WCAG 2.5.3).
+- **Keep labels short, unique, and free of role words and state.**
+- **Translate labels,** and consider hidden text for multilingual sites.
+- **Check names** in the Accessibility panel, and with voice control and a screen reader.
+
+Next, learn how to name something from text that is already on the page with [aria-labelledby](./aria-labelledby).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/aria-labelledby.mdx b/tutorials/html/13-accessibility/aria-labelledby.mdx
index e69de29bb..153bfd9f8 100644
--- a/tutorials/html/13-accessibility/aria-labelledby.mdx
+++ b/tutorials/html/13-accessibility/aria-labelledby.mdx
@@ -0,0 +1,621 @@
+---
+id: html-aria-labelledby
+title: "Aria Labelledby"
+sidebar_label: "Aria Labelledby"
+sidebar_position: 18
+slug: /html/accessibility/aria-labelledby
+description: "Learn how aria-labelledby names an element from text that is already on the page: naming dialogs and sections from their headings, building names from several pieces, making repeated buttons unique, how hidden text and self-reference work, and why unique ids matter. Includes a name builder lab, a duplicate id lab, and a live aria-labelledby checker."
+keywords:
+ - aria-labelledby
+ - accessible name
+ - id reference
+ - name dialog from heading
+ - duplicate ids
+ - aria-label vs aria-labelledby
+ - label in name
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, aria, aria-labelledby, accessible-name, a11y, frontend]
+---
+
+import {useState, useEffect} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export const nameScenarios = [
+ {
+ id: "row",
+ title: "An Edit button in a table row",
+ role: "button",
+ defaults: ["edit", "name"],
+ nodes: [
+ {key: "name", text: "Rye bread"},
+ {key: "price", text: "5.75 dollars"},
+ {key: "edit", text: "Edit"},
+ ],
+ },
+ {
+ id: "dialog",
+ title: "A dialog named by its heading",
+ role: "dialog",
+ defaults: ["title"],
+ nodes: [
+ {key: "title", text: "Delete recipe?"},
+ {key: "body", text: "This cannot be undone."},
+ ],
+ },
+ {
+ id: "region",
+ title: "A section named by its heading",
+ role: "region",
+ defaults: ["title"],
+ nodes: [
+ {key: "title", text: "Delivery"},
+ {key: "body", text: "Tuesday to Saturday."},
+ ],
+ },
+ {
+ id: "input",
+ title: "An input named from two pieces of text",
+ role: "edit text",
+ defaults: ["label", "unit"],
+ nodes: [
+ {key: "label", text: "Amount"},
+ {key: "unit", text: "in US dollars"},
+ ],
+ },
+];
+
+export function NameBuilderLab() {
+ const [index, setIndex] = useState(0);
+ const [ids, setIds] = useState(nameScenarios[0].defaults);
+ const [typo, setTypo] = useState(false);
+ const [hide, setHide] = useState(false);
+ const scn = nameScenarios[index];
+ const full = (key) => "lblby-" + scn.id + "-" + key;
+ const listed = ids.map(full).concat(typo ? ["lblby-" + scn.id + "-nmae"] : []);
+ const attr = listed.length > 0 ? listed.join(" ") : undefined;
+ const found = ids.map((key) => scn.nodes.find((node) => node.key === key)).filter(Boolean);
+ const name = found.map((node) => node.text).join(" ");
+ const choose = (next) => {
+ setIndex(next);
+ setIds(nameScenarios[next].defaults);
+ setTypo(false);
+ setHide(false);
+ };
+ const toggle = (key) => setIds(ids.indexOf(key) === -1 ? ids.concat(key) : ids.filter((item) => item !== key));
+ const hideStyle = (key) => (hide && key !== "edit" ? {display: "none"} : undefined);
+ const node = (key) => scn.nodes.find((item) => item.key === key);
+ const notes = [];
+ if (ids.length === 0) notes.push("No ids are listed, so aria-labelledby does nothing, and the name comes from somewhere else.");
+ if (typo) notes.push("The id \"" + full("nmae") + "\" does not exist (a typo), so it is silently ignored. Browsers do not warn you.");
+ if (scn.id === "row" && ids.indexOf("edit") === -1 && ids.length > 0) notes.push("The button's own text, \"Edit\", is no longer in the name, because aria-labelledby replaces the content. Include the button's own id to keep it (this is called self-reference). Voice control users who say \"click Edit\" would not match.");
+ if (scn.id === "row" && ids.length > 0 && ids[0] !== "edit" && ids.indexOf("edit") !== -1) notes.push("The order matters: the name is built in the order of the ids. Visible text usually comes first, so it starts with \"Edit\".");
+ if (hide && ids.length > 0) notes.push("The referenced text is hidden with display: none, and it is still used in the name. That is why aria-labelledby can name something from text that nobody sees.");
+ if (notes.length === 0) notes.push("This works: every id exists, and the order reads naturally.");
+ return (
+
+
+ Pick an example
+ choose(Number(event.target.value))}>
+ {nameScenarios.map((item, i) => ({item.title} ))}
+
+
+
+ Which text should name it? (Tick them in the order you want them read.)
+ {scn.nodes.map((item) => (
+
+ toggle(item.key)} /> {item.text} {"#" + full(item.key)}{ids.indexOf(item.key) !== -1 ? " (number " + (ids.indexOf(item.key) + 1) + ")" : ""}
+
+ ))}
+ setTypo(event.target.checked)} /> Add a mistyped id
+ setHide(event.target.checked)} /> Hide the text on screen with display: none
+
+
+
The element:
+ {scn.id === "row" ? (
+
+ {node("name").text}
+ {node("price").text}
+ {node("edit").text}
+
+ ) : null}
+ {scn.id === "dialog" ? (
+
+
{node("title").text}
+
{node("body").text}
+
Delete {" "}
+
Cancel
+
+ ) : null}
+ {scn.id === "region" ? (
+
+ {node("title").text}
+ {node("body").text}
+
+ ) : null}
+ {scn.id === "input" ? (
+
+ {node("label").text}
+
+ {node("unit").text}
+
+ ) : null}
+
The attribute: {attr ? 'aria-labelledby="' + attr + '"' : "(none)"}
+
A screen reader may say: {(name || "(no name from aria-labelledby)") + ", " + scn.role}
+
+ {notes.map((note, i) => ({note} ))}
+
+
+
+ The attribute is real and sits on a real element, so you can inspect it with your browser's tools. The spoken line is a simplified example, and different screen readers word it differently.
+
+
+ );
+}
+
+export const cardData = [
+ {name: "Sourdough", blurb: "A tangy loaf with a crackly crust."},
+ {name: "Rye", blurb: "Dark, dense, and a little sweet."},
+ {name: "Focaccia", blurb: "Soft, oily, and full of herbs."},
+];
+
+export function DuplicateIdLab() {
+ const [unique, setUnique] = useState(false);
+ const [names, setNames] = useState([]);
+ useEffect(() => {
+ const result = cardData.map((card, i) => {
+ const link = document.getElementById("dup-link-" + i);
+ if (!link) return "";
+ return link.getAttribute("aria-labelledby").split(/\s+/).map((id) => {
+ const target = document.getElementById(id);
+ return target ? target.textContent.trim() : "";
+ }).join(" ");
+ });
+ setNames(result);
+ }, [unique]);
+ const titleId = (i) => (unique ? "dup-title-" + i : "dup-title");
+ const wrong = names.filter((name, i) => name.indexOf(cardData[i].name) === -1).length;
+ return (
+
+
+ setUnique(event.target.checked)} /> Give each heading its own unique id
+
+
+ {cardData.map((card, i) => (
+
+ ))}
+
+
The names a screen reader gets for the three links:
+
+ {names.map((name, i) => ({'"' + name + '"'} {name.indexOf(cardData[i].name) === -1 ? (wrong: this is the {cardData[i].name} card) : null} ))}
+
+
{wrong === 0 ? "Every link has the right name." : wrong + " link(s) have the wrong name."}
+
+ With the same id on all three headings, getElementById always finds the first one, so every link is named after the first card. The code in this lab asks the browser to find each id, the same way assistive technology does. This is a very common bug in components that are used more than once, and the fix is to build the id from something unique, such as an index or a generated id.
+
+
+ );
+}
+
+export const labelledbySamples = {
+ messy: '\nEdit \nRye bread \nSourdough \nRye \nRead more \nSearch \n \n
\nPlain div
\nX ',
+ fixed: '\nEdit \nRye bread \nSourdough \nRead more \nRye \nRead more \nSearch \n ',
+};
+
+export function lbText(node) {
+ if (node.nodeType === 3) return node.textContent;
+ if (node.nodeType !== 1) return "";
+ if (node.tagName.toLowerCase() === "img") return node.getAttribute("alt") || "";
+ let out = "";
+ node.childNodes.forEach((child) => { out += " " + lbText(child) + " "; });
+ return out;
+}
+
+export function LabelledbyCheckerInner() {
+ const [which, setWhich] = useState("messy");
+ const [html, setHtml] = useState(labelledbySamples.messy);
+ const doc = new DOMParser().parseFromString("" + html, "text/html");
+ const idCounts = {};
+ Array.from(doc.querySelectorAll("[id]")).forEach((el) => { idCounts[el.id] = (idCounts[el.id] || 0) + 1; });
+ const elements = Array.from(doc.querySelectorAll("[aria-labelledby]"));
+ const rows = elements.map((el) => {
+ const tag = el.tagName.toLowerCase();
+ const role = el.getAttribute("role") || "";
+ const raw = el.getAttribute("aria-labelledby").trim();
+ const ids = raw.split(/\s+/).filter(Boolean);
+ const issues = [];
+ const notes = [];
+ const parts = [];
+ if (ids.length === 0) issues.push("An empty aria-labelledby does nothing.");
+ ids.forEach((id) => {
+ const target = doc.getElementById(id);
+ if (!target) {
+ issues.push("The id \"" + id + "\" does not exist, so it is ignored.");
+ return;
+ }
+ if (idCounts[id] > 1) issues.push("The id \"" + id + "\" is used " + idCounts[id] + " times. Only the first one is used.");
+ const text = lbText(target).replace(/\s+/g, " ").trim();
+ if (text === "") issues.push("The element with the id \"" + id + "\" has no text, so it adds nothing to the name.");
+ else parts.push(text);
+ });
+ const name = parts.join(" ");
+ if (el.hasAttribute("aria-label")) notes.push("It also has aria-label. aria-labelledby wins, so the aria-label is ignored.");
+ if (tag === "input" && el.getAttribute("id") && Array.from(doc.querySelectorAll("label")).some((label) => label.getAttribute("for") === el.getAttribute("id"))) notes.push("A visible label is connected too, and aria-labelledby overrides it. Remove one of them.");
+ if (!role && /^(div|span|p|b|i|em|strong|small|u)$/.test(tag)) issues.push("A name on a plain " + tag + " (no role) is not reliably announced. Give it a role, or restructure the HTML.");
+ const textual = tag === "button" || tag === "a" || role === "button" || role === "link";
+ const own = lbText(el).replace(/\s+/g, " ").trim();
+ if (textual && own && name && name.toLowerCase().indexOf(own.toLowerCase()) === -1) issues.push("The visible text \"" + own + "\" is not in the name \"" + name + "\" (WCAG 2.5.3). Include the element's own id in the list to keep it.");
+ return {tag, role, raw, name, issues, notes};
+ });
+ const bad = rows.reduce((sum, row) => sum + row.issues.length, 0);
+ const choose = (value) => { setWhich(value); setHtml(labelledbySamples[value]); };
+ return (
+
+
+ Which HTML do you want to check?
+ choose("messy")} /> Messy references
+ choose("fixed")} /> The fixed version
+
+
+ HTML to check (edit it freely)
+ setHtml(event.target.value)} style={{width: "100%", fontFamily: "monospace"}} />
+
+
{rows.length} elements use aria-labelledby, with {bad} problems.
+
+ {rows.map((row, index) => (
+
+ {"<" + row.tag + (row.role ? " role=" + row.role : "") + ' aria-labelledby="' + row.raw + '">'} {row.name ? named {'"' + row.name + '"'} : no name }{" "}
+ {row.issues.length === 0 ? OK : Problem: {row.issues.join(" ")} }
+ {row.notes.length > 0 ? Note: {row.notes.join(" ")} : null}
+
+ ))}
+
+
+ This reads the HTML only, with a simplified version of the naming rules. It cannot tell whether the chosen text makes a good name, so read each result and ask: does this make sense on its own?
+
+
+ );
+}
+
+export function LabelledbyChecker() {
+ return {() => } ;
+}
+
+Last time you learned `aria-label`, which lets you **write** an invisible name. But often the best name for something is **already on the page.** A dialog has a heading. A section has a title. A table row has the name of the item. It would be wasteful (and fragile) to type the same words again in an attribute.
+
+**`aria-labelledby`** solves that. Instead of writing the name, you **point to** the text that already is the name, using its `id`. The name stays in one place, is visible to everyone, and is translated along with the page.
+
+
+
+
+## What You Will Learn
+
+- What **`aria-labelledby`** does, and how it **builds a name** from other elements
+- How to **name dialogs, sections,** and **groups** from their headings
+- How to **combine** several pieces of text, and why **order** matters
+- How **self-reference** keeps a button's own text
+- Why **hidden text** still counts
+- Why ids must be **unique** and **correct**
+- How to **choose** between `label`, `aria-label`, and `aria-labelledby`
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+
+
Delete recipe?
+
This cannot be undone.
+
+
+
+
+
+
+Rye bread
+Edit
+
+
+Amount
+
+in US dollars
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Try each example in the lab. Untick an id, change the order, and add the mistyped id, and watch the name change.
+
+## How It Works
+
+The value of `aria-labelledby` is a **list of ids,** separated by spaces. The browser:
+
+1. **Finds** each element with that id, in the whole page.
+2. **Takes the text** of each one (including text of nested elements, and the `alt` of images).
+3. **Joins them,** in the order listed, with a space between.
+4. **Uses the result as the name** of the element that has the attribute.
+
+Some important details:
+
+- **It comes first.** `aria-labelledby` is at the top of the naming order. It beats `aria-label`, native labels, and the element's own content. (See [Accessible Names](./accessible-names).)
+- **It replaces the content.** A button with `aria-labelledby` is named from the pointed-at text, **not** its own. If you want to keep its own text, **include its own id** in the list. This is called **self-reference,** and it is used in the "Edit Rye bread" example.
+- **Order matters.** "Edit Rye bread" and "Rye bread Edit" are different. Put the most important words first, and the visible text first.
+- **Hidden text counts.** An element hidden with `display: none`, `visibility: hidden`, the `hidden` attribute, or `aria-hidden="true"` is still used when you point to it. That is very handy: you can put a name in a hidden element, and use it for the label.
+- **Missing ids are ignored,** silently. A typo gives you no error, just no name.
+- **Ids must be in the same page** (the same document). You cannot point across an `iframe` or into another page.
+- **It names, it does not describe.** For extra help text, use `aria-describedby` instead. See [aria-describedby](./aria-describedby).
+
+## When to Use It
+
+### Name a dialog or region from its heading
+
+```html
+
+
Subscribe
+ …
+
+```
+
+Dialogs, regions, forms, and groups **need names,** and the heading is the natural one. This keeps the name and the heading in step: change the heading, and the name changes too.
+
+### Make repeated buttons and links unique
+
+```html
+
+
+ Rye bread
+ Edit
+ Delete
+
+
+```
+
+Every row says "Edit" and "Delete," but each button is named "Edit Rye bread," "Delete Rye bread," and so on. The visible text is first, so voice control still works (WCAG 2.5.3).
+
+### Name something from several pieces
+
+A field with a prefix or a unit, a card with a title and a price, a progress bar with a heading: list all the pieces, in reading order.
+
+### Name a widget from text that is hidden on purpose
+
+If an icon-only control has hidden text in the page, point to it. That gives you a name that **translates like normal text.**
+
+## When Not to Use It
+
+| Situation | Better choice |
+| :-- | :-- |
+| A form field and a visible label next to it | `label` with `for`. It also makes the click area bigger. |
+| An icon-only button with no text anywhere | `aria-label`, or visually hidden text |
+| The name is just the element's own text | Nothing. Content names it already. |
+| You want to explain, not name | `aria-describedby` |
+| A plain `div` or `span` with no role | Give it a role, or restructure. A name there is not reliably announced. |
+| An element that has both a `label` and `aria-labelledby` | Choose one. `aria-labelledby` overrides the label. |
+
+## Choosing Between the Three
+
+| | Where the name comes from | Visible? | Translates with the page? |
+| :-- | :-- | :-- | :-- |
+| **`label`** | A connected `label` element | Yes | Yes |
+| **`aria-labelledby`** | Other text on the page, pointed to by id | Usually, or hidden on purpose | Yes (it is real text) |
+| **`aria-label`** | A string you write in the attribute | No | Maybe not, so test |
+
+A simple rule: **visible text first** (`label`, or the content). If the text is somewhere else on the page, use **`aria-labelledby`.** If there is no text at all, use **`aria-label`.**
+
+## Unique and Correct Ids
+
+`aria-labelledby` only works if the ids are **right.** Two things go wrong most often:
+
+1. **A typo or a missing element.** The reference is ignored, and nothing tells you.
+2. **The same id used twice.** Browsers return the **first** match, so every copy of a component points to the **first** heading. This happens a lot when a card or a dialog is used many times.
+
+Try the lab below. It reads the real ids from the page, the way assistive technology does.
+
+
+
+
+```html title="index.html"
+
+Sourdough
+Read more
+Rye
+Read more
+
+
+Sourdough
+Read more
+Rye
+Read more
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+How to keep ids unique:
+
+- **Build ids from a unique value:** an item id, an index, or a counter (`"title-" + item.id`).
+- **In a framework,** use its built-in id helper (such as React's `useId`) instead of fixed ids inside components that repeat.
+- **Never use spaces** in an id. A space would split it into two ids.
+- **Keep the referenced element in the page** while the label is needed. If it is removed or re-created with a different id, the name is lost.
+
+## Check Your References
+
+The checker below finds the usual `aria-labelledby` problems: ids that do not exist, duplicate ids, referenced elements with no text, a name that loses the visible text, labels that conflict with `aria-label` or a visible `label`, and names on plain `div` or `span` elements.
+
+
+<>
+
+>
+
+
+On a real site, you can also:
+
+1. **Open DevTools,** select the element, and look at the Accessibility panel. Many browsers show the name, and which element it came from.
+2. **Change the referenced text,** and check that the name changes too.
+3. **Test with voice control:** say "click" and the visible text of each button.
+4. **Use a screen reader** on dialogs and regions, and listen for the names.
+5. **Run an automated checker,** which will find missing and duplicate ids.
+
+## Good Habits
+
+1. **Prefer visible text,** and point to it with `aria-labelledby` when it is elsewhere on the page.
+2. **List the ids in a natural reading order,** with the visible text first.
+3. **Include the element's own id** when you want to keep its own text.
+4. **Use unique ids,** built from something unique.
+5. **Name dialogs, regions, and forms** from their headings.
+6. **Do not mix `label`, `aria-label`, and `aria-labelledby`** on the same element.
+7. **Check every id** in the list exists.
+8. **Keep referenced text short,** so the name is short.
+9. **Test with a screen reader** and voice control.
+
+## Accessibility Checklist
+
+- [ ] Every id in every `aria-labelledby` exists, and is unique on the page.
+- [ ] The referenced elements have text.
+- [ ] The visible text of a control is included in its name, and comes first.
+- [ ] Dialogs, regions, and forms are named from their headings.
+- [ ] Repeated buttons and links have unique names.
+- [ ] Controls do not have a `label`, `aria-label`, and `aria-labelledby` all at once.
+- [ ] `aria-labelledby` is not used on plain `div` or `span` elements.
+- [ ] Names make sense on their own, and are not too long.
+- [ ] Referenced text is translated along with the page.
+- [ ] Names were checked in the Accessibility panel and with real assistive technology.
+
+## Security and Privacy Notes
+
+- **Hidden text can be read.** Text you point to with `aria-labelledby` may be hidden on screen, but it is still in the page source, and a screen reader will read it aloud. Do not put private data or internal notes in it.
+- **Do not use it to mislead.** Naming a button "Cancel" from hidden text when it really confirms a payment is a deceptive pattern that harms people who cannot see the screen.
+- **Be careful with user data.** If a name is built from text that users can edit (such as a username or an item title), clean that text before putting it in the page, to prevent script injection.
+- **Ids can be targeted.** Page scripts and extensions can find elements by id. Do not rely on a hidden id to protect anything.
+- **Do not use it to detect assistive technology.** For example, checking whether hidden label text was read could reveal a disability.
+- **Sensitive names:** "Delete message from Sam" may be read aloud near other people. Think about this for private content, and about how names appear in shared screens and recordings.
+
+## Global Audience Notes
+
+- **It translates well.** Because `aria-labelledby` points to real text on the page, translation tools translate the name along with everything else. That makes it a good choice for multilingual sites, and often safer than `aria-label`.
+- **Order may need care in other languages.** "Edit Rye bread" reads naturally in English. In some languages the item name or action may need to come in a different order. Build the order with translators in mind, and test it.
+- **Right-to-left languages:** the name is built in the order of the ids, which follows the reading logic and not the screen position. Check that mixed text (numbers, Latin words) reads sensibly.
+- **Set `lang`** on the page, and on any referenced text in another language, so the voice pronounces each part correctly.
+- **Keep referenced text short and plain.** It is easier to translate, and quicker to hear in a list of controls.
+- **Voice control users speak the visible label** in their own language. Keep the visible text first in the name.
+
+## Common Mistakes to Avoid
+
+1. **A typo in an id,** so nothing is named.
+2. **The same id used on several elements.**
+3. **Listing ids in the wrong order.**
+4. **Forgetting the element's own id,** and losing its visible text.
+5. **Using it together with `aria-label` or a visible `label`.**
+6. **Pointing to an element that is empty,** or removed by script.
+7. **`aria-labelledby` on a plain `div` or `span`.**
+8. **Using it to describe things,** instead of `aria-describedby`.
+9. **Very long names** built from many pieces.
+10. **Putting private data in hidden text.**
+11. **Using ids with spaces.**
+12. **Never checking the result** in the Accessibility panel.
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the name builder lab with every example, and write down the final name for each.
+2. Change the order of the ids in the "Edit" example, and say which order is best, and why.
+3. Add the mistyped id, and explain why nothing breaks, but the name is wrong.
+4. In the duplicate id lab, fix the problem, and explain how.
+5. Run the checker on the messy page, fix every problem, and run it again.
+6. Name a dialog from its heading, and check it in the Accessibility panel.
+7. Build a list of three rows, each with "Edit" and "Delete" buttons that include the item name.
+8. Hide a text with `display: none`, and use it as a name with `aria-labelledby`.
+9. Replace an `aria-label` in a project with `aria-labelledby`, using text that is already visible.
+10. Bonus: in a component that repeats, generate a unique id for each copy, and test with three copies.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What does `aria-labelledby` do?
+
+It names an element from the text of one or more other elements on the page, found by their ids.
+
+
+
+
+2. What does the order of ids do?
+
+The name is built in the order the ids are listed, so put the visible text, and the most important words, first.
+
+
+
+
+3. What is self-reference?
+
+Including the element's own id in its `aria-labelledby`, to keep its own text in the name, such as "Edit Rye bread".
+
+
+
+
+4. Does hidden text count?
+
+Yes. A referenced element that is hidden (with `display: none`, `hidden`, or `aria-hidden`) is still used to build the name.
+
+
+
+
+5. What happens if an id in the list does not exist?
+
+It is silently ignored. You get no error, so the name may be missing or wrong.
+
+
+
+
+6. Why is the same id on several elements a problem?
+
+Browsers use the first match, so every copy points to the first one. Use unique ids built from something unique, such as an item id.
+
+
+
+## Key Takeaways
+
+- **`aria-labelledby` points to text that already exists,** so the name stays in one place and translates with the page.
+- **It comes first** in the naming order, and replaces the element's own content.
+- **Use self-reference** to keep the visible text, and put it first.
+- **Hidden text still counts.** Do not put private data in it.
+- **Ids must exist and be unique.** A typo or a duplicate fails silently.
+- **Choose:** `label` for form fields, `aria-labelledby` for text elsewhere, `aria-label` when there is no text.
+- **Check** in the Accessibility panel, and with voice control and a screen reader.
+
+Next, learn how to add extra help to a control with [aria-describedby](./aria-describedby).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/aria-roles.mdx b/tutorials/html/13-accessibility/aria-roles.mdx
index e69de29bb..c3fcb8289 100644
--- a/tutorials/html/13-accessibility/aria-roles.mdx
+++ b/tutorials/html/13-accessibility/aria-roles.mdx
@@ -0,0 +1,584 @@
+---
+id: html-aria-roles
+title: "ARIA Roles"
+sidebar_label: "ARIA Roles"
+sidebar_position: 15
+slug: /html/accessibility/aria-roles
+description: "Learn what ARIA roles are, how native HTML elements already have them, and which roles are worth knowing: landmark, widget, live region, and structure roles. Includes a searchable role explorer, a landmark lab that shows what a screen reader's landmark list looks like, and a live roles lab for status, alert, and log."
+keywords:
+ - ARIA roles
+ - role attribute
+ - landmark roles
+ - role status alert
+ - role dialog
+ - implicit roles
+ - role presentation
+ - tablist tab tabpanel
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, aria, roles, landmarks, a11y, frontend]
+---
+
+import {useState} from 'react';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import AdsComponent from '@site/src/components/AdsComponent';
+import BrowserWindow from '@site/src/components/BrowserWindow';
+
+export function makeRole(role, cat, native, name, keys, note, code) {
+ return {role, cat, native, name, keys, note, code};
+}
+
+export const roleData = [
+ makeRole("banner", "Landmark", "header (page level)", "No", "None", "The site header. Use it once per page.", ''),
+ makeRole("navigation", "Landmark", "nav", "Recommended when there is more than one", "None", "A block of navigation links. Name each one if you have several.", '… '),
+ makeRole("main", "Landmark", "main", "No", "None", "The main content. Use it once per page.", '… '),
+ makeRole("complementary", "Landmark", "aside", "Recommended when there is more than one", "None", "Related content that makes sense on its own, such as a sidebar.", ''),
+ makeRole("contentinfo", "Landmark", "footer (page level)", "No", "None", "The site footer, with copyright and links.", ''),
+ makeRole("search", "Landmark", "search element, or form with role=search", "Recommended", "None", "The search area of the page.", '\n \n Search \n '),
+ makeRole("form", "Landmark", "form (only when it has a name)", "Required to be a landmark", "None", "A form is a landmark only if it has an accessible name.", '… '),
+ makeRole("region", "Landmark", "section (only when it has a name)", "Required to be a landmark", "None", "Use sparingly. Too many regions make a landmark list noisy.", ''),
+ makeRole("button", "Widget", "button", "Required", "Enter and Space", "Something you press to do an action.", 'Save '),
+ makeRole("checkbox", "Widget", "input type=checkbox", "Required", "Space", "A two- or three-state choice. A custom one needs aria-checked.", ' \nRemember me '),
+ makeRole("switch", "Widget", "None (checkbox with role=switch)", "Required", "Space", "An on and off setting. Needs aria-checked.", 'Dark mode '),
+ makeRole("textbox", "Widget", "input type=text, textarea", "Required", "Typing", "A place to type text.", 'Name \n '),
+ makeRole("slider", "Widget", "input type=range", "Required", "Arrow keys, Home, End", "Needs aria-valuenow, aria-valuemin, and aria-valuemax if custom.", 'Volume \n '),
+ makeRole("progressbar", "Widget", "progress", "Recommended", "None", "Shows how far a task has got.", 'Upload \n '),
+ makeRole("tablist", "Widget", "None", "Recommended", "Left and Right arrows", "Holds the tabs. Its children must be tab elements.", '…
'),
+ makeRole("tab", "Widget", "None", "Required", "Arrow keys, roving tabindex", "One tab. Needs aria-selected and aria-controls.", 'Ingredients '),
+ makeRole("tabpanel", "Widget", "None", "Required", "None", "The content for a tab. Link it with aria-labelledby.", '…
'),
+ makeRole("combobox", "Widget", "select, or input with datalist", "Required", "Typing, arrows, Esc", "A complex pattern. Use native select where you can, and follow a tested pattern otherwise.", 'Loaf \n… '),
+ makeRole("listbox", "Widget", "select with multiple", "Required", "Arrow keys", "A list you choose from. Its children are option elements.", '… '),
+ makeRole("menu", "Widget", "None", "Recommended", "Arrow keys, Esc", "For application-style menus (like a right-click menu). Do not use it for site navigation links.", ''),
+ makeRole("tooltip", "Widget", "None", "Not needed", "Esc closes it", "A small popup, linked with aria-describedby. It must show on focus too.", 'Help \nOpens the help centre.
'),
+ makeRole("status", "Live region", "output", "No", "None", "A polite message, announced when the screen reader is free. Good for \"Saved.\"", 'Recipe saved.
'),
+ makeRole("alert", "Live region", "None", "No", "None", "An urgent message, announced at once. Use rarely.", 'Your session ends in one minute.
'),
+ makeRole("log", "Live region", "None", "Recommended", "None", "A history where new items are added, such as chat.", '…
'),
+ makeRole("timer", "Live region", "None", "Recommended", "None", "A timer. It is not announced as it changes, unless you ask for it.", '04:59
'),
+ makeRole("list", "Structure", "ul, ol", "No", "None", "A list. Its children are listitem elements.", ''),
+ makeRole("heading", "Structure", "h1 to h6", "Content", "None", "A custom heading also needs aria-level.", 'Ingredients '),
+ makeRole("img", "Structure", "img", "Required", "None", "An image. Also useful on an SVG, or a group of emoji, that should be read as one picture.", '… '),
+ makeRole("table", "Structure", "table", "Recommended", "None", "Data in rows and columns. See Accessible Tables.", ''),
+ makeRole("group", "Structure", "fieldset, details", "Recommended", "None", "A set of related things. A custom group needs a name.", '…
'),
+ makeRole("separator", "Structure", "hr", "No", "None", "A divider between sections.", ' '),
+ makeRole("article", "Structure", "article", "No", "None", "A self-contained piece, such as a post or card.", '… '),
+ makeRole("presentation / none", "Structure", "None", "No", "None", "Removes the role of an element. Never use it on something focusable.", ''),
+ makeRole("dialog", "Window", "dialog", "Required", "Esc closes it", "A window on top of the page. Move focus in, and back out when it closes.", '… '),
+ makeRole("alertdialog", "Window", "None (dialog with role=alertdialog)", "Required", "Esc closes it", "A dialog that needs an answer now, such as a confirmation. Focus the safe choice.", '…
'),
+];
+
+export const roleCategories = ["All", "Landmark", "Widget", "Live region", "Structure", "Window"];
+
+export function RoleExplorer() {
+ const [category, setCategory] = useState("All");
+ const [query, setQuery] = useState("");
+ const [picked, setPicked] = useState("button");
+ const list = roleData.filter((item) => (category === "All" || item.cat === category) && (query.trim() === "" || item.role.indexOf(query.trim().toLowerCase()) !== -1 || item.native.toLowerCase().indexOf(query.trim().toLowerCase()) !== -1));
+ const current = roleData.find((item) => item.role === picked) || list[0] || roleData[0];
+ return (
+
+
+
+ Category
+ setCategory(event.target.value)}>
+ {roleCategories.map((name) => ({name} ))}
+
+
+
+ Search by role or element
+ setQuery(event.target.value)} />
+
+
+
{list.length} roles shown. Choose one to see the details.
+
+ {list.map((item) => (
+ setPicked(item.role)} style={{padding: "0.2rem 0.6rem", fontWeight: current.role === item.role ? 700 : 400, background: current.role === item.role ? "#dbeafe" : undefined, color: current.role === item.role ? "#1e293b" : undefined, border: current.role === item.role ? "2px solid #1d4ed8" : undefined}}>{item.role}
+ ))}
+
+
+
{current.role} ({current.cat})
+
Native HTML: {current.native}
+
Needs a name: {current.name}
+
Keyboard: {current.keys}
+
{current.note}
+
{current.code}
+
+
+ This is a selection of the most useful roles, not the whole list. For a custom widget, always follow a tested pattern, because a role also brings rules about names, keyboard keys, required children, and states.
+
+
+ );
+}
+
+export function LandmarkLab() {
+ const [semantic, setSemantic] = useState(true);
+ const [nameSecond, setNameSecond] = useState(false);
+ const [useSearch, setUseSearch] = useState(true);
+ const [useAside, setUseAside] = useState(true);
+ const [regionMode, setRegionMode] = useState("none");
+ const landmarks = [];
+ const problems = [];
+ if (semantic) {
+ landmarks.push("banner");
+ landmarks.push("navigation: Main");
+ landmarks.push(nameSecond ? "navigation: Footer links" : "navigation");
+ if (!nameSecond) problems.push("Two navigation landmarks, and one has no name. Someone browsing landmarks cannot tell them apart.");
+ if (useSearch) landmarks.push("search: Recipes");
+ landmarks.push("main");
+ if (useAside) landmarks.push("complementary: Related recipes");
+ if (regionMode === "named") landmarks.push("region: Delivery");
+ if (regionMode === "unnamed") problems.push("A section with no name is not a landmark at all, so it is missing from the list.");
+ landmarks.push("contentinfo");
+ } else {
+ problems.push("Only div elements, so there are no landmarks. Someone using a landmark list finds nothing, and must read the whole page in order.");
+ }
+ const lines = [];
+ lines.push(semantic ? "" : "");
+ lines.push(semantic ? "… " : "");
+ if (useSearch) lines.push(semantic ? "… " : "…
");
+ lines.push(semantic ? "" : "");
+ if (regionMode !== "none") lines.push(semantic ? (regionMode === "named" ? "
" : "
") : "
…
");
+ lines.push(semantic ? "" : "
");
+ if (useAside) lines.push(semantic ? "" : "…
");
+ lines.push(semantic ? "… " : "…
");
+ lines.push(semantic ? "" : "");
+ return (
+
+
+ Build the page
+ setSemantic(event.target.checked)} /> Use landmark elements (header, nav, main, footer)
+ setNameSecond(event.target.checked)} disabled={!semantic} /> Name the second nav ("Footer links")
+ setUseSearch(event.target.checked)} /> Add a search area
+ setUseAside(event.target.checked)} /> Add a sidebar (aside)
+
+ A section for delivery {" "}
+ setRegionMode(event.target.value)}>
+ None
+ A section with no name
+ A section with a name
+
+
+
+
The HTML:
+
{lines.join("\n")}
+
+
A screen reader's landmark list:
+ {landmarks.length === 0 ?
(no landmarks found)
: (
+
+ {landmarks.map((item, index) => ({item} ))}
+
+ )}
+
+ {problems.length > 0 ? (
+
+ {problems.map((item, index) => (Problem: {item} ))}
+
+ ) :
Looks good: every landmark is there, and every repeated one has its own name.
}
+
+ Screen readers can list the landmarks of a page, or jump from one to the next, so people can skip straight to the main content or the search. The names and the list shown here are a simplified example of that, and each screen reader words it a little differently. Also note that the header and footer are page-level landmarks only when they sit directly in the page, and not inside an article or section.
+
+
+ );
+}
+
+export function LiveRolesLab() {
+ const [statusText, setStatusText] = useState("");
+ const [alertText, setAlertText] = useState("");
+ const [logLines, setLogLines] = useState([]);
+ const [transcript, setTranscript] = useState([]);
+ const note = (line) => setTranscript((previous) => previous.concat(line).slice(-7));
+ const save = () => {
+ if (statusText === "Recipe saved.") note("status: the text did not change, so nothing new is announced.");
+ else {
+ setStatusText("Recipe saved.");
+ note("status: \"Recipe saved.\" Announced politely, when the screen reader is not busy.");
+ }
+ };
+ const fail = () => {
+ if (alertText === "Could not save. Check your connection.") note("alert: the text did not change, so nothing new is announced.");
+ else {
+ setAlertText("Could not save. Check your connection.");
+ note("alert: \"Could not save...\" Announced at once, and may interrupt what is being read.");
+ }
+ };
+ const chat = () => {
+ const line = "Message " + (logLines.length + 1) + " from Sam";
+ setLogLines(logLines.concat(line));
+ note("log: \"" + line + "\" Added to the end, and announced politely.");
+ };
+ const reset = () => {
+ setStatusText("");
+ setAlertText("");
+ setLogLines([]);
+ setTranscript([]);
+ };
+ const box = {border: "1px solid #94a3b8", borderRadius: "8px", padding: "0.5rem 0.7rem", marginBottom: "0.5rem", minHeight: "2.4rem"};
+ return (
+
+
+ Save recipe
+ Make an error
+ Add a chat line
+ Reset
+
+
role="status" (polite)
+
{statusText}
+
role="alert" (assertive)
+
{alertText}
+
role="log" (a history)
+
+ {logLines.map((line, index) => (
{line}
))}
+
+
What a screen reader would do (simulation):
+ {transcript.length === 0 ?
Press a button.
: (
+
+ {transcript.map((line, index) => ({line} ))}
+
+ )}
+
+ These are real live regions, so if you use a screen reader, you will hear them. The list above is only a plain-text simulation of the difference. Two things matter: the region must already be in the page before its text changes, and an identical message is not announced again. The exact behaviour depends on the screen reader and browser, so test it. Do not use alert for ordinary messages, because it interrupts people.
+
+
+ );
+}
+
+A **role** tells assistive technology **what an element is.** "This is a button." "This is a navigation area." "This is a tab." It is the first thing a screen reader tells you about something, and it decides how a person expects to use it: a button is pressed, a checkbox is toggled, a link is followed.
+
+The key point, which you met in the last lesson, is that **native HTML elements already have roles.** A `button` is a button, a `nav` is a navigation landmark, an `h2` is a level 2 heading. The `role` attribute is for the situations where HTML has no element that fits.
+
+
+
+
+## What You Will Learn
+
+- What a **role** is, and how **native elements** already have them
+- The main **families of roles:** landmark, widget, live region, structure, and window
+- Which roles are worth **knowing**
+- What a role **promises,** and what you must still build
+- Roles to **avoid**
+- How to **check** roles
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+
+…
+…
+Save
+Ingredients
+
+
+
+
+ Ingredients
+
+
+…
+
+
+Recipe saved.
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Browse the list by category, search for an element such as `nav`, and look at the "Native HTML" line first. Most of the time it says an element exists.
+
+## How Roles Work
+
+- **Every element has a role,** either **implicit** (from the HTML element) or **explicit** (from the `role` attribute).
+- **An explicit role wins** over the implicit one. `` is a button to assistive technology, even though it is a `div` in the code.
+- **A role is a promise.** It tells people what to expect: a `button` should respond to Enter and Space, a `tab` should respond to arrow keys, a `dialog` should keep focus inside. The role does **not** keep the promise for you. You must write the behaviour.
+- **Each role has rules.** Some need a **name** (`dialog`, `img`), some need **children** of a certain kind (`tablist` needs `tab`, `list` needs `listitem`), and some need **states** (`checkbox` needs `aria-checked` if it is custom).
+- **A role needs to fit the element.** The "ARIA in HTML" rules list which roles are allowed on which elements. For example, you may not put `role="button"` on a heading, or on a link, without changing what the element is.
+- **The `role` attribute can hold a list** of words, and browsers use the first one they understand. It is rarely needed, and support is not something to rely on, so use one role.
+- **Roles are read by the browser's accessibility tree,** which you saw in [Introduction to Accessibility](./introduction-to-a11y).
+
+## The Families of Roles
+
+| Family | What it covers | Examples |
+| :-- | :-- | :-- |
+| **Landmark** | Big areas of the page | `banner`, `navigation`, `main`, `search`, `contentinfo` |
+| **Widget** | Things you interact with | `button`, `checkbox`, `tab`, `slider`, `combobox` |
+| **Live region** | Messages that change | `status`, `alert`, `log`, `timer` |
+| **Document structure** | The shape of content | `list`, `heading`, `img`, `table`, `group` |
+| **Window** | Pop-up windows | `dialog`, `alertdialog` |
+
+There is also a group of **abstract** roles used to define the others. **Never** use them in your pages.
+
+## Landmark Roles
+
+Landmarks mark the **big areas** of a page. Screen reader users can list them, or jump between them, much as sighted users glance at the layout. They are the single biggest help for people who need to get to the main content, the search box, or the menu without reading everything in order.
+
+You almost never need the `role` attribute for them, because HTML elements do the job:
+
+| Element | Landmark role |
+| :-- | :-- |
+| `header` (directly in the page) | `banner` |
+| `nav` | `navigation` |
+| `main` | `main` |
+| `aside` | `complementary` |
+| `footer` (directly in the page) | `contentinfo` |
+| `section` (with a name) | `region` |
+| `form` (with a name) | `form` |
+| `search`, or `form role="search"` | `search` |
+
+Rules of thumb:
+
+- **One `main`** per page, and use `header` and `footer` for the page (not for sections).
+- **Name repeated landmarks,** with `aria-label` or `aria-labelledby`: two `nav` elements need different names.
+- **Do not repeat the role in the name:** "Main," not "Main navigation."
+- **Use `section` and `region` sparingly.** Too many landmarks are as unhelpful as none.
+- **Put everything inside a landmark.** Content outside any landmark is easy to miss.
+- **A newer `search` element exists** in recent HTML. Support is growing, so check your target browsers, and use `role="search"` on a form as the safe option.
+
+
+
+
+```html title="index.html"
+
+
+…
+
+
+
+ Search
+
+
+
+ Sourdough
+ …
+
+
+
+
+…
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+## Widget Roles
+
+Widget roles describe **interactive** things. Native elements cover many of them (`button`, `checkbox`, `radio`, `textbox`, `slider`, `progressbar`, `option`). A few have **no native element,** so you need roles to build them: `tab`, `tablist`, `tabpanel`, `switch`, `menu`, `menuitem`, `tooltip`, and a full `combobox`.
+
+Using a widget role is a **contract.** It means you will provide:
+
+1. **A name.**
+2. **The right states** (for example `aria-selected` on a tab).
+3. **The keyboard behaviour** people expect (arrow keys inside tabs and menus).
+4. **Focus handling** (a roving `tabindex` in a tab list, for instance).
+5. **The right structure** (tabs inside a tablist).
+
+If you cannot provide all five, **do not use the role.** A half-built widget confuses people more than a plain one.
+
+:::warning
+**`menu` is not for website navigation.** A `role="menu"` tells a screen reader to switch into a special mode where the arrow keys are the way to move, like an application menu. A site's main menu is a list of links, so use `nav`, `ul`, and `a`.
+:::
+
+## Live Region Roles
+
+Some roles tell assistive technology to **announce changes** to a region, **without moving focus.** The page must already contain the region, and then you change the text in it.
+
+| Role | Announced | Use it for |
+| :-- | :-- | :-- |
+| `status` | Politely (when the reader is free) | "Recipe saved," "3 results found" |
+| `alert` | At once, and may interrupt | Urgent errors, "Session ending" |
+| `log` | Politely, new items added | Chat, activity history |
+| `timer` | Not announced as it changes | A countdown, with a name |
+
+Try them in the lab. If you use a screen reader, you will hear the real thing.
+
+
+<>
+
+>
+
+
+Choose the **gentlest** option that works. Most messages should be `status`, and `alert` should be rare. Over-using `alert` is like shouting: it interrupts people, and makes them stop trusting your messages. The next lessons cover live regions in depth, in [aria-live](./aria-live).
+
+## Structure and Special Roles
+
+Most structure roles come from HTML: `ul` and `ol` (`list`), `li` (`listitem`), `h1` to `h6` (`heading`), `table`, `img`, `figure`, `hr` (`separator`), `article`. A few explicit roles are worth knowing:
+
+- **`role="img"`** makes a group of things (an SVG, a few emoji) read as **one image,** with a name from `aria-label`.
+- **`role="group"`** with a name gathers related controls, such as a set of toolbar buttons. For form fields, prefer `fieldset` and `legend`.
+- **`role="presentation"` or `role="none"`** removes the role of an element. It is useful to turn a **layout table** into plain content, or to hide a `li` inside a custom widget. **Never put it on something focusable or interactive.**
+- **`role="list"` on a `ul` with `list-style: none`.** Some browser and screen reader combinations (notably Safari with VoiceOver) have stopped treating such a list as a list. Adding the role puts it back. This behaviour has changed over time, so check what your audience's browsers do before adding it everywhere.
+
+## Roles to Avoid
+
+- **Abstract roles** (such as `widget`, `landmark`, `command`). They are only for the ARIA specification.
+- **`role="application"`.** It tells screen readers to hand over **all** the keys to your page, and turn off their own navigation. Almost no page needs it.
+- **`role="menu"` for site navigation.** Use a `nav` with links.
+- **A role that repeats the element:** `
`, ``. It does nothing useful, and clutters your code.
+- **A role that fights the element:** `
+ Focus is on: {where}
+
+ );
+}
+
+export const startItems = ["Sourdough", "Rye", "Focaccia", "Baguette"];
+
+export function DeleteFocusLab() {
+ const [items, setItems] = useState(startItems);
+ const [manage, setManage] = useState(true);
+ const [where, setWhere] = useFocusReadout();
+ const pending = useRef(null);
+ const refs = useRef({});
+ const headingRef = useRef(null);
+ const previousCount = useRef(startItems.length);
+ const remove = (name) => {
+ const index = items.indexOf(name);
+ const rest = items.filter((item) => item !== name);
+ if (manage) pending.current = rest.length === 0 ? "heading" : rest[Math.min(index, rest.length - 1)];
+ else pending.current = null;
+ setItems(rest);
+ };
+ useEffect(() => {
+ if (items.length === previousCount.current) return;
+ previousCount.current = items.length;
+ const target = pending.current;
+ pending.current = null;
+ if (target === "heading" && headingRef.current) headingRef.current.focus();
+ else if (target && refs.current[target]) refs.current[target].focus();
+ else if (document.activeElement === document.body) setWhere("the page itself (focus was lost)");
+ }, [items]);
+ return (
+
+
+ setManage(event.target.checked)} /> Manage focus after a remove
+
+
{items.length === 0 ? "Your basket is empty" : "Your basket (" + items.length + (items.length === 1 ? " item)" : " items)")}
+
+ {items.map((name) => (
+
+ {name}
+ { refs.current[name] = element; }} onClick={() => remove(name)}>Remove
+
+ ))}
+
+
setItems(startItems)}>Put the loaves back
+
+
+ Tab to a Remove button and press Enter. With management on, focus moves to the next item (or the heading when the list is empty). With it off, the button you were on disappears, and focus falls back to the page itself, so the next Tab starts again from the top (or from somewhere unpredictable, depending on the browser). Some browsers (Safari, and Firefox on macOS) do not focus a button when you click it, so use Tab and Enter for the most reliable test.
+
+
+ );
+}
+
+export function DialogFocusLab() {
+ const [open, setOpen] = useState(false);
+ const [moveIn, setMoveIn] = useState(true);
+ const [giveBack, setGiveBack] = useState(true);
+ const [trap, setTrap] = useState(true);
+ const [where, setWhere] = useFocusReadout();
+ const openerRef = useRef(null);
+ const dialogRef = useRef(null);
+ const fieldRef = useRef(null);
+ const wasOpen = useRef(false);
+ useEffect(() => {
+ if (open && !wasOpen.current && moveIn && fieldRef.current) fieldRef.current.focus();
+ if (!open && wasOpen.current) {
+ if (giveBack && openerRef.current) openerRef.current.focus();
+ else if (document.activeElement === document.body) setWhere("the page itself (focus was lost)");
+ }
+ wasOpen.current = open;
+ }, [open]);
+ const onKeyDown = (event) => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ return;
+ }
+ if (event.key !== "Tab" || !trap || !dialogRef.current) return;
+ const focusable = dialogRef.current.querySelectorAll("button, input, a[href], select, textarea");
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+ return (
+
+
+ Turn each behaviour on or off
+ setMoveIn(event.target.checked)} /> Move focus into the dialog when it opens
+ setTrap(event.target.checked)} /> Keep Tab inside the dialog while it is open
+ setGiveBack(event.target.checked)} /> Return focus to the opener when it closes
+
+
+ Home
+ Recipes
+ setOpen(true)}>Open the subscribe dialog
+ Contact
+
+ {open ? (
+
+
Subscribe to the newsletter
+
+ Email address
+
+
+
setOpen(false)}>Subscribe {" "}
+
setOpen(false)}>Cancel
+
+ ) : null}
+
+
+ Try it three ways. First with everything on. Then turn off "move focus in" and open the dialog: focus stays on the button behind it, and Esc stops working, because the key press happens outside the dialog. Then turn off "keep Tab inside" and press Tab past Cancel: focus walks out into the page behind the dialog. This lab uses a hand-built dialog so you can switch each part off. The native dialog element does all three for you.
+
+
+ );
+}
+
+export function ErrorSummaryLab() {
+ const [move, setMove] = useState(true);
+ const [values, setValues] = useState({name: "", email: ""});
+ const [errors, setErrors] = useState({});
+ const [attempts, setAttempts] = useState(0);
+ const [done, setDone] = useState(false);
+ const [where] = useFocusReadout();
+ const summaryRef = useRef(null);
+ const nameRef = useRef(null);
+ const emailRef = useRef(null);
+ const doneRef = useRef(null);
+ const submit = (event) => {
+ event.preventDefault();
+ const found = {};
+ if (values.name.trim() === "") found.name = "Enter your name.";
+ if (values.email.indexOf("@") === -1) found.email = "Enter an email address that includes an @ sign.";
+ setErrors(found);
+ setDone(Object.keys(found).length === 0);
+ setAttempts((count) => count + 1);
+ };
+ useEffect(() => {
+ if (attempts === 0 || !move) return;
+ if (done && doneRef.current) doneRef.current.focus();
+ else if (summaryRef.current) summaryRef.current.focus();
+ }, [attempts]);
+ const jumpTo = (ref) => (event) => {
+ event.preventDefault();
+ if (ref.current) ref.current.focus();
+ };
+ const reset = () => {
+ setValues({name: "", email: ""});
+ setErrors({});
+ setDone(false);
+ setAttempts(0);
+ };
+ const keys = Object.keys(errors);
+ return (
+
+
+ setMove(event.target.checked)} /> Move focus after Submit
+
+ {keys.length > 0 ? (
+
+
There {keys.length === 1 ? "is 1 problem" : "are " + keys.length + " problems"}
+
+
+ ) : null}
+ {done ? (
+
+ Thanks, {values.name}! Your subscription is confirmed. (This is a demo, so nothing is sent.)
+
+ ) : null}
+
+
+ Name
+ setValues({...values, name: event.target.value})} />
+ {errors.name ? Error: {errors.name} : null}
+
+
+ Email
+ setValues({...values, email: event.target.value})} />
+ {errors.email ? Error: {errors.email} : null}
+
+ Submit {" "}
+ Start again
+
+
+
+ Leave both fields empty and press Submit. With focus management on, focus jumps to the problem summary, whose links take you to each field. With it off, focus stays on the Submit button, and the errors appear above it, where a keyboard or screen reader user may never notice them.
+
+
+ );
+}
+
+Where you put keyboard focus is where a keyboard or screen reader user's attention goes. Most of the time the browser looks after it for you: you press Tab, focus moves to the next control. But when your page **changes by itself** (a dialog opens, an item is deleted, a form shows errors, a new "page" loads without a refresh), the browser cannot know where the person should go next. If you do nothing, focus is often **lost**, and the person has to start again from the top of the page.
+
+**Focus management** means moving focus **on purpose,** at the right moments, to the right place. Done well, nobody notices it. Done badly, a page becomes exhausting, or unusable.
+
+
+
+
+## What You Will Learn
+
+- What **focus** is, and how to read it with `document.activeElement`
+- How the **`focus()`** method works, and why it sometimes does nothing
+- **When** to move focus, and when to leave it alone
+- Making a heading or container **focusable by script** with `tabindex="-1"`
+- Managing focus in **dialogs,** and why the native `dialog` element helps
+- Handling **form errors** and **page changes** in single-page apps
+- Never **stealing** focus
+- How to **debug** and test focus
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+Your basket (4 items)
+
+
+
+ Sourdough
+ Remove
+
+
+ Rye
+ Remove
+
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+When you remove the element that has focus, the browser has nowhere to keep it, so focus drops back to the page itself. A keyboard user has lost their place. The fix is one line of script: **choose a sensible new home for focus.**
+
+## What Is Focus?
+
+At any moment, **at most one element** on the page has keyboard focus. That is the element that receives key presses, and the one a screen reader is looking at. When nothing specific has focus, the browser treats the `body` (the page itself) as focused.
+
+You can ask which element has focus at any time:
+
+```js
+document.activeElement; // the element with focus (the body if none)
+```
+
+| Code | What it does |
+| :-- | :-- |
+| `element.focus()` | Moves focus to the element, if it can be focused and is visible. |
+| `element.focus({ preventScroll: true })` | Moves focus without scrolling the page to it. |
+| `element.blur()` | Removes focus from the element. Rarely the right choice, because focus then falls to the page. |
+| `document.activeElement` | The element that currently has focus. |
+| `focusin` and `focusout` events | Fire when focus enters or leaves an element. Unlike `focus` and `blur`, they **bubble,** so one listener can watch a whole area. |
+
+### Why `focus()` Sometimes Does Nothing
+
+The `focus()` call fails silently when the element:
+
+- **is not focusable.** A plain `div` or `h2` has no `tabindex`. Add `tabindex="-1"` first.
+- **is hidden.** `display: none`, `visibility: hidden`, and the `hidden` attribute all prevent focus.
+- **is disabled or `inert`.**
+- **is not in the page yet.** In a framework, call `focus()` **after** the new content has been drawn (for example in `useEffect`, `nextTick`, or after the render step), not before.
+
+:::tip
+If `focus()` seems to be ignored, log `document.activeElement` right after the call. If it is still the old element, one of the four reasons above is almost always the cause.
+:::
+
+## Focus on Things That Are Not Controls
+
+Sometimes you want to move focus to something that is not a control: a **heading,** an **error summary,** a **dialog container,** or a **new page title.** Give it `tabindex="-1"`. That makes it focusable **by script,** but keeps it **out of the Tab order,** so keyboard users do not trip over it.
+
+```html
+Recipes
+
+
+```
+
+Browsers may draw a focus ring on an element focused this way. That is fine, and often helpful, because it shows where you are. Do not hide it. If the ring looks odd on a big heading, style it with `:focus-visible` rather than removing it (see [Keyboard Accessibility](./keyboard-accessibility)).
+
+## When to Move Focus
+
+Not every change needs focus to move. Use this table as a guide:
+
+| Situation | What to do |
+| :-- | :-- |
+| A **modal dialog** opens | Move focus **into** the dialog (to its first field, or its heading). |
+| A dialog **closes** | **Return** focus to the element that opened it. |
+| The focused item is **deleted** | Move focus to the next item, the previous one, or the list heading. |
+| A **form is submitted** with errors | Move focus to an **error summary,** or to the first invalid field. |
+| A **form succeeds** | Move focus to the success message, or to the next step. |
+| A **single-page app changes "page"** | Move focus to the new page heading (or the main area), and update the title. |
+| A menu or accordion is **opened by the user** | Usually leave focus on the control. Move it only if the design needs it. |
+| A **toast, status, or search count** appears | **Do not** move focus. Use a live region instead. See [ARIA Live Regions](./aria-live). |
+| Content **loads in the background** | **Do not** move focus. The person is busy somewhere else. |
+
+A simple rule: **move focus when the person's action caused a change of context, and leave it when something just updates nearby.**
+
+## Dialogs
+
+A modal dialog is the clearest case for focus management. It needs **four** things:
+
+1. **Move focus in** when it opens.
+2. **Keep Tab inside** while it is open, so keyboard users do not wander into the page behind.
+3. **Close with Esc.**
+4. **Return focus** to the opener when it closes.
+
+
+
+
+```html title="index.html"
+Subscribe
+
+
+ Subscribe to the newsletter
+
+
+ Email address
+
+
+ Subscribe
+ Cancel
+
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+The native `dialog` element, opened with `showModal()`, does a lot of this for you. In current browsers it moves focus into the dialog, makes the rest of the page **inert** (so Tab and screen readers cannot reach it), closes on Esc, and returns focus to the element that was focused before. Browser behaviour here has improved over the years, so **test in the browsers your audience uses.** If one of them does not return focus, store the opener yourself and call `focus()` in the dialog's `close` event.
+
+A few extra points:
+
+- **Choose the right starting point.** Normally focus goes to the first field or button. For a **destructive** question ("Delete this recipe?"), put the `autofocus` attribute on the **safe** button (Cancel), so an accidental Enter does no damage. For a long text dialog (such as terms), focus the **heading** (`tabindex="-1"`) so the reader starts at the top.
+- **Do not build your own unless you must.** A hand-built dialog needs a focus trap, an Esc handler, `aria-modal`, an inert background, and focus return, all tested with a screen reader. The lab above shows how many moving parts that is.
+- **A focus trap is not a keyboard trap.** Keeping Tab inside a modal is correct, **because there is a clear way out** (Esc, or a Close button). A keyboard trap is when there is **no** way out. See the [previous lesson](./keyboard-accessibility).
+- **Non-modal popups** (a small menu, a tooltip-like panel) should not trap focus. Let Tab continue, and close the popup when focus leaves it, or on Esc.
+
+## Forms and Errors
+
+When someone submits a form and it has errors, a mouse user **sees** the red messages. A keyboard or screen reader user, whose focus is still on the Submit button, may notice **nothing at all.** There are two good fixes:
+
+- **An error summary** at the top of the form: a short list of problems, each one a **link** to the field. Move focus to the summary when the form fails.
+- **Focus the first invalid field,** with its error message connected by `aria-describedby`.
+
+The summary is usually friendlier, because it tells you **how many** problems there are, and lets you jump between them.
+
+
+
+
+```html title="index.html"
+
+
+
+ Email
+
+ Error: Enter an email address that includes an @ sign.
+
+ Subscribe
+
+
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Notice that the errors in the lab say **"Error:"** in words, not just in colour, and that each summary link points to a real `id`. Both help people who cannot see colour, and people who cannot use a mouse.
+
+## Single-Page Apps and Page Changes
+
+On a normal website, clicking a link loads a **new page.** The browser resets focus to the top, a screen reader announces the new title, and everyone knows something happened. In a **single-page app,** the script swaps the content without a reload, and **none of that happens.** Focus stays on the link that was clicked, which may no longer be in the page.
+
+After a route change, you need to do the browser's job yourself:
+
+```js
+function onRouteChange(newTitle) {
+ document.title = newTitle; // 1. update the title
+
+ const heading = document.querySelector("h1");
+ heading.setAttribute("tabindex", "-1"); // 2. make it focusable by script
+ heading.focus(); // 3. move focus to the new page
+}
+```
+
+Most modern routers and frameworks have an accessibility option or plugin for this. Whatever you use, **test it with a keyboard and a screen reader.** Do the same for large changes such as "next step" in a checkout, or "load more" results (where focusing the first **new** result is a good idea).
+
+## Do Not Steal Focus
+
+Moving focus is powerful, which means it is easy to abuse. **Stealing focus** (moving it when the person did not ask) is one of the most disorienting things a page can do. A screen reader user suddenly hears a different part of the page. A keyboard user loses their place. A person typing is interrupted mid-word.
+
+- **`autofocus` on page load:** use it with great care. On a page whose **only purpose** is one field (a search page, a login page), it can help. On any other page it makes screen reader users skip the page content, and on phones it pops up the on-screen keyboard and hides the page.
+- **Focus changes from timers,** chat widgets, newsletter pop-ups, and cookie banners that grab focus in the middle of a task.
+- **Focus changes when content loads** in the background.
+- **Moving focus when the user is typing** in another field.
+
+The rule is: **only move focus in response to something the person did,** or at a clear change of context.
+
+:::warning
+Never use `blur()` to get rid of a focus outline after a click. It does not fix the "ugly outline" problem, it breaks keyboard navigation, and the focus lands on the page, which is exactly the "lost focus" problem this lesson is about.
+:::
+
+## Focus and Scrolling
+
+When you call `focus()`, the browser scrolls the element into view. That is usually what you want, but watch for these cases:
+
+- **Sticky headers** can cover the focused element. Fix it with `scroll-padding-top` on `html` (see the previous lesson).
+- **Huge jumps.** If you focus something far down the page, the sudden scroll can disorient people. Use `focus({ preventScroll: true })` only if you scroll to the right place yourself.
+- **Smooth scrolling** can cause motion discomfort. Respect the user's setting with `@media (prefers-reduced-motion: reduce)`.
+
+## Debugging Focus
+
+You cannot fix what you cannot see. Two handy tricks:
+
+```js
+// Paste this in your browser console, then press Tab through your page
+document.addEventListener("focusin", () => {
+ console.log("Focus is now on:", document.activeElement);
+});
+```
+
+In Chromium-based browsers, the DevTools console can also show a **live expression** of `document.activeElement`, which updates as you move around. Many browsers also have an **accessibility panel** in DevTools that shows an element's role and name. If focus is "lost," you will see `body` there.
+
+## Test With Your Own Keyboard
+
+Test each of these with **no mouse,** and once with a screen reader if you can:
+
+1. **Open a dialog.** Does focus move in? Does Tab stay inside? Does Esc close it?
+2. **Close it.** Does focus return to the button that opened it?
+3. **Delete an item,** or close a banner. Where does focus go? It must not vanish.
+4. **Submit a form with errors.** Are you taken to the problem? Can you jump to each field?
+5. **Navigate to another "page"** in your app. Is the new title announced, and does focus move to the new heading?
+6. **Load more results,** switch tabs, or open an accordion. Does focus behave as expected?
+7. **Look for stolen focus.** Does anything grab focus without you asking?
+8. **Look at the log.** After each step, is `document.activeElement` somewhere sensible, and never the `body`?
+
+## Good Habits
+
+1. **Use native elements** that manage focus for you, such as `dialog`, `details`, and links.
+2. **Always return focus** when you close something that you opened.
+3. **Never leave focus on an element you remove.** Move it first, or move it right after.
+4. **Use `tabindex="-1"`** for targets that only script should focus.
+5. **Move focus after the new content is in the page,** not before.
+6. **Give people a way to leave** anything that holds their focus.
+7. **Prefer a status message** (live region) over moving focus for small updates.
+8. **Only move focus after a person's own action.**
+9. **Test with a keyboard and a screen reader** every time you change an interaction.
+
+## Accessibility Checklist
+
+- [ ] Opening a modal moves focus into it, and closing it returns focus to the opener.
+- [ ] While a modal is open, the page behind it cannot be reached with Tab or a screen reader.
+- [ ] Every modal and popup can be closed with Esc, and with a visible Close button.
+- [ ] Removing or hiding the focused element moves focus somewhere sensible.
+- [ ] Failed forms move focus to an error summary or to the first invalid field.
+- [ ] Single-page apps update the title and move focus after each route change.
+- [ ] Nothing steals focus without a user action.
+- [ ] `autofocus` is used only where it clearly helps.
+- [ ] Focus is never lost to the `body` after an action.
+- [ ] Small updates use a live region instead of moving focus. See [ARIA Live Regions](./aria-live).
+
+## Security and Privacy Notes
+
+- **Do not use focus tricks to mislead.** Moving focus to a hidden or disguised button (so that an Enter press confirms something else) is a form of clickjacking, and is also an accessibility failure.
+- **Never auto-focus a sensitive field** (such as a password or card number) on a page that includes third-party content or adverts, because typing could be captured by the wrong element.
+- **Be careful with sensitive confirmations.** Focus the **safe** choice (Cancel) first in dialogs for deleting, paying, or sharing.
+- **Do not log focus or navigation patterns** to an analytics server without consent. How someone moves through a page can reveal that they use assistive technology, and that is sensitive information.
+- **Keep your debugging code out of production.** Console logging of `document.activeElement` can expose the names and contents of fields.
+- **Embedded frames:** a focus trap should not depend on content from another site. If an iframe from another origin is inside your dialog, you cannot control focus inside it, so test that people can leave it.
+
+## Global Audience Notes
+
+- **Slow networks and old devices:** if content loads slowly, move focus only **after** it has appeared. Moving focus to something that is not there yet makes it silently fail, and leaves people lost.
+- **Phones:** focusing a text field opens the on-screen keyboard, which uses much of the screen. Do it only when a person is clearly about to type.
+- **Right-to-left languages:** focus order follows the page source, and flips with the layout when you use `dir="rtl"`. Test your dialogs and error summaries in a right-to-left layout.
+- **Translation:** error summaries, headings, and dialog titles are the first things a screen reader reads after focus moves. Keep them short, translated correctly, and set the page language with `lang` so voices pronounce them correctly.
+- **Input methods:** for languages that use an input method editor (Chinese, Japanese, Korean), do not move focus while a person is still composing text.
+- **Varied abilities and devices:** people reach the web by keyboard, switch, voice, and touch screen readers. Focus management helps all of them, because it keeps a **single clear place** for attention.
+
+## Common Mistakes to Avoid
+
+1. **Deleting the focused element** and doing nothing.
+2. **Opening a dialog** and leaving focus behind it.
+3. **Closing a dialog** without returning focus.
+4. **Calling `focus()` before the element exists,** or while it is hidden.
+5. **Focusing a heading without `tabindex="-1"`.**
+6. **Errors that appear above the button,** while focus stays on the button.
+7. **Single-page apps that never move focus or change the title.**
+8. **Using `autofocus` everywhere.**
+9. **Using `blur()` to hide an outline.**
+10. **A focus trap with no way out.**
+11. **Moving focus for every little update** instead of using a live region.
+12. **Never testing with the keyboard.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Use the delete lab with focus management off. Press Tab after a delete and describe what happens.
+2. Use the dialog lab, and turn off one behaviour at a time. Write down what breaks.
+3. Open a native `dialog` with `showModal()`. Check where focus starts, and where it goes after closing.
+4. Put `autofocus` on the Cancel button in a "Delete this?" dialog. Does it feel safer?
+5. Add an error summary to a form you built before, with links that jump to each field.
+6. Write a small script that logs `document.activeElement` on `focusin`, and Tab through your own site.
+7. Make a heading focusable with `tabindex="-1"`, and call `focus()` on it from a button.
+8. Find a site with a cookie banner. Does it steal focus? Does it return focus when you close it?
+9. Build a "load more" button that moves focus to the first new item.
+10. Bonus: do the "Test With Your Own Keyboard" list on a page you made last month.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What happens to focus when the focused element is removed from the page?
+
+It falls back to the page itself (the `body`). Keyboard and screen reader users lose their place, so you should move focus to a sensible element yourself.
+
+
+
+
+2. Why would `focus()` do nothing?
+
+The element may not be focusable (no `tabindex`), may be hidden, disabled, or `inert`, or may not be in the page yet. Fix whichever one applies, and call `focus()` after the content is rendered.
+
+
+
+
+3. Why use `tabindex="-1"` on a heading?
+
+It makes the heading focusable by script (so you can move focus there), without adding it to the normal Tab order.
+
+
+
+
+4. What four things should a modal dialog do?
+
+Move focus in when it opens, keep Tab inside while it is open, close with Esc, and return focus to the opener when it closes.
+
+
+
+
+5. How should a form handle errors for keyboard and screen reader users?
+
+Move focus to an error summary (with links to each field), or to the first invalid field, and connect each error message to its field with `aria-describedby`. Do not rely on colour alone.
+
+
+
+
+6. When should you not move focus?
+
+When something just updates nearby (a toast, a status, a search count), when content loads in the background, or when the person did not cause the change. Use a live region for those.
+
+
+
+## Key Takeaways
+
+- **Focus is attention.** When your page changes, it is your job to put it somewhere sensible.
+- **Never leave focus on an element that disappears.** Move it to a neighbour, a heading, or a container.
+- Use `tabindex="-1"` to make **headings and containers** focusable by script only.
+- **Dialogs** move focus in, keep Tab inside, close with Esc, and **return focus** to the opener. The native `dialog` element helps a lot.
+- **Failed forms** should lead people to an **error summary** or the first problem.
+- **Single-page apps** must update the title and move focus after route changes.
+- **Do not steal focus.** Move it only after something the person did.
+- Check `document.activeElement` while you test. It should never be the `body` after an action.
+
+Next, learn how elements get their names, and why those names matter, in [Accessible Names](./accessible-names).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/index.mdx b/tutorials/html/13-accessibility/index.mdx
index 39d37f45b..0d5f8c1cb 100644
--- a/tutorials/html/13-accessibility/index.mdx
+++ b/tutorials/html/13-accessibility/index.mdx
@@ -1,25 +1,524 @@
+---
+id: accessibility-overview
+title: "HTML Accessibility: Building for Everyone"
+sidebar_label: "Accessibility"
+sidebar_position: 1
+slug: /html/accessibility
+description: "Start learning web accessibility: why it matters, who it helps, the four WCAG principles (perceivable, operable, understandable, robust), and a map of every lesson in this section. Includes a live experience simulator, a contrast checker, and a short drill that sorts real barriers into the four principles."
+keywords:
+ - HTML accessibility
+ - web accessibility for beginners
+ - WCAG principles POUR
+ - screen reader basics
+ - keyboard accessibility
+ - ARIA introduction
+ - color contrast checker
+ - accessible HTML
+ - a11y
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, a11y, wcag, aria, frontend]
+---
+
+import {useState} from 'react';
+
+export function ExperienceSim() {
+ const [vision, setVision] = useState("none");
+ const [blur, setBlur] = useState(false);
+ const [lowContrast, setLowContrast] = useState(false);
+ const [zoom, setZoom] = useState(100);
+ const [noMouse, setNoMouse] = useState(false);
+ const [hideFocus, setHideFocus] = useState(false);
+ const parts = [];
+ if (blur) parts.push("blur(1.5px)");
+ if (vision === "grayscale") parts.push("grayscale(1)");
+ if (vision === "protanopia") parts.push("url(#a11y-protanopia)");
+ if (vision === "deuteranopia") parts.push("url(#a11y-deuteranopia)");
+ if (vision === "tritanopia") parts.push("url(#a11y-tritanopia)");
+ if (lowContrast) parts.push("contrast(0.45)");
+ return (
+
+
+
+
+
+
+
+
+
+
+ Change how the sample page is experienced
+ Colour vision{" "}
+ setVision(event.target.value)}>
+ Typical
+ No colour (grayscale)
+ Red-weak or red-blind (protanopia, approximate)
+ Green-weak or green-blind (deuteranopia, approximate)
+ Blue-weak (tritanopia, approximate)
+
+
+ Text size{" "}
+ setZoom(Number(event.target.value))}>
+ 100 percent
+ 200 percent
+ 300 percent
+
+
+ setBlur(event.target.checked)} /> Blurred vision
+ setLowContrast(event.target.checked)} /> Low contrast (glare or a dim screen)
+ setNoMouse(event.target.checked)} /> No mouse: only the keyboard works
+ setHideFocus(event.target.checked)} /> Hide the focus outline (a common mistake)
+
+
0 ? parts.join(" ") : "none", pointerEvents: noMouse ? "none" : "auto", fontSize: zoom + "%", border: "1px solid #94a3b8", borderRadius: "8px", padding: "0.8rem", background: "#ffffff", color: "#1e293b", overflow: "auto"}}>
+
Join The Bread Bin
+
Weekly recipes, sent every Friday. event.preventDefault()} style={{color: "#1d4ed8"}}>Read our story
+
+ Email address
+
+
+
+ Subscribe {" "}
+ No thanks
+
+
Green means subscribe. Red means no thanks.
+
+
+ {noMouse ? "Mouse use is switched off for the sample. Press Tab to move through it, and Enter or Space to press a button. " : ""}
+ {hideFocus ? "With the outline hidden, can you still tell where you are on the page? " : ""}
+ {vision !== "none" ? "Look at the two buttons. Could you tell them apart without relying on colour? " : ""}
+ {!noMouse && !hideFocus && vision === "none" && !blur && !lowContrast && zoom === 100 ? "Try the options above, one at a time." : ""}
+
+
+ These are rough simulations for building understanding. They are not a medical model of any condition, and no simulation replaces testing with, and listening to, real disabled people.
+
+
+ );
+}
+
+export const toLinear = (value) => {
+ const channel = value / 255;
+ return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
+};
+
+export const relativeLuminance = (hex) => {
+ const value = hex.replace("#", "");
+ const red = parseInt(value.slice(0, 2), 16);
+ const green = parseInt(value.slice(2, 4), 16);
+ const blue = parseInt(value.slice(4, 6), 16);
+ return 0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue);
+};
+
+export const contrastOf = (first, second) => {
+ const a = relativeLuminance(first);
+ const b = relativeLuminance(second);
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
+};
+
+export function ContrastChecker() {
+ const [fg, setFg] = useState("#767676");
+ const [bg, setBg] = useState("#ffffff");
+ const [large, setLarge] = useState(false);
+ const ratio = contrastOf(fg, bg);
+ const result = (needed) => (ratio >= needed ? "Pass" : "Fail");
+ const rows = [
+ {label: "AA, " + (large ? "large text" : "normal text"), needed: large ? 3 : 4.5},
+ {label: "AAA, " + (large ? "large text" : "normal text"), needed: large ? 4.5 : 7},
+ {label: "AA, icons and input borders (non-text)", needed: 3},
+ ];
+ return (
+
+
+ Text colour setFg(event.target.value)} />
+ Background colour setBg(event.target.value)} />
+ setLarge(event.target.checked)} /> Large text (24px, or 19px bold)
+
+
+ The quick brown fox jumps over the lazy dog.
+
+
Contrast ratio: {ratio.toFixed(2)} : 1
+
+
+ Requirement Needs at least Result
+
+
+ {rows.map((row) => (
+
+ {row.label}
+ {row.needed} : 1
+ {result(row.needed)}
+
+ ))}
+
+
+
+ The default grey (#767676 on white) is the lightest grey that just passes for normal text. The ratio uses the formula from the Web Content Accessibility Guidelines. It cannot judge font weight, thin letter shapes, or images behind text, so always look at the real result too.
+
+
+ );
+}
+
+export const barriers = [
+ {text: "A photo of a product has no text alternative.", answer: "Perceivable", why: "People who cannot see the image cannot perceive its content."},
+ {text: "A menu opens only when you hover over it with a mouse.", answer: "Operable", why: "Keyboard and touch users cannot operate it."},
+ {text: "An error message says only \"Invalid input\", without saying which field or why.", answer: "Understandable", why: "People cannot understand what went wrong or how to fix it."},
+ {text: "A custom toggle looks on or off, but never tells assistive technology its state.", answer: "Robust", why: "Assistive technology cannot reliably interpret the control."},
+ {text: "A video has speech but no captions.", answer: "Perceivable", why: "People who cannot hear the audio cannot perceive the speech."},
+ {text: "A pop-up window traps keyboard focus, with no way to close it using the keyboard.", answer: "Operable", why: "Keyboard users cannot operate their way out."},
+ {text: "The page language is not set, so a screen reader pronounces the text with the wrong voice.", answer: "Understandable", why: "The language of the page needs to be identified so the text can be understood."},
+ {text: "A control is built from div elements with no role, so assistive technology cannot tell it is a button.", answer: "Robust", why: "The content is not built so that a wide range of tools can interpret it."},
+];
+
+export function PourDrill() {
+ const [index, setIndex] = useState(0);
+ const [picked, setPicked] = useState(null);
+ const [score, setScore] = useState(0);
+ const done = index >= barriers.length;
+ const item = barriers[index];
+ const choose = (choice) => {
+ if (picked !== null) return;
+ setPicked(choice);
+ if (choice === item.answer) setScore(score + 1);
+ };
+ const next = () => {
+ setPicked(null);
+ setIndex(index + 1);
+ };
+ const restart = () => {
+ setIndex(0);
+ setPicked(null);
+ setScore(0);
+ };
+ const options = ["Perceivable", "Operable", "Understandable", "Robust"];
+ if (done) {
+ return (
+
+
You finished. Score: {score} out of {barriers.length} .
+
Try again
+
+ );
+ }
+ return (
+
+
Barrier {index + 1} of {barriers.length}
+
{item.text}
+
Which principle does it break?
+
+ {options.map((option) => (
+ choose(option)} disabled={picked !== null} style={{marginRight: "0.4rem", marginBottom: "0.3rem"}}>{option}
+ ))}
+
+
+ {picked === null ? null : (
+
+ {picked === item.answer ? "Correct." : "Not quite. The best answer is " + item.answer + "."} {item.why}
+
+ )}
+
+ {picked !== null ? (
{index === barriers.length - 1 ? "See my score" : "Next barrier"} ) : null}
+
+ Many real barriers fit more than one principle. In this drill, pick the one that fits best. You will see all four principles in detail in the lesson on WCAG basics.
+
+
+ );
+}
+
+Think of a building with a grand staircase and no ramp. A lot of people can enter easily. But someone using a wheelchair cannot, a parent with a pushchair struggles, and a delivery worker with a heavy trolley is stuck at the bottom. Nobody planned to exclude them. The designer simply did not think about them.
+
+Websites are the same. A page can look beautiful and still lock people out: a button that works only with a mouse, a picture with no description, a form that never says what went wrong. **Web accessibility** is the practice of building pages so that **as many people as possible** can use them, whatever their abilities, devices, or circumstances.
+
+The good news: HTML was designed with accessibility in mind. Most of the work is **choosing the right element** and **writing clear content.** This section teaches you how.
+
+
+
+
+## What You Will Learn in This Section
+
+- **Why** accessibility matters, and **who** it helps
+- The four principles of the **Web Content Accessibility Guidelines (WCAG)**
+- How people **really use** the web: screen readers, keyboards, magnifiers, voice, and switches
+- Building with **semantic HTML,** the foundation of accessible pages
+- Writing accessible **names, images, links, buttons, forms, and tables**
+- **ARIA:** what it is, when to use it, and when **not** to
+- **Keyboard** use and **focus management**
+- **Testing** your work
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+ Join The Bread Bin
+
+
+ Email address
+
+
+ Subscribe
+
+
+
+
```
- ├── 13-accessibility/
- │ ├── index.mdx
- │ ├── introduction-to-a11y.mdx
- │ ├── wcag-basics.mdx
- │ ├── semantic-accessibility.mdx
- │ ├── keyboard-accessibility.mdx
- │ ├── screen-readers.mdx
- │ ├── focus-management.mdx
- │ ├── accessible-names.mdx
- │ ├── alt-text.mdx
- │ ├── accessible-forms.mdx
- │ ├── accessible-tables.mdx
- │ ├── accessible-links.mdx
- │ ├── accessible-buttons.mdx
- │ ├── aria-introduction.mdx
- │ ├── aria-roles.mdx
- │ ├── aria-states-properties.mdx
- │ ├── aria-label.mdx
- │ ├── aria-labelledby.mdx
- │ ├── aria-describedby.mdx
- │ ├── aria-live.mdx
- │ ├── aria-hidden.mdx
- │ └── accessibility-testing.mdx
-```
\ No newline at end of file
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Try each option, one at a time. Turn on **"No mouse"** and press Tab. Turn on **"Hide the focus outline"** and try again. Then switch to a colour-vision option, and look at the two buttons. Small choices in HTML and CSS make a very big difference for real people.
+
+## Who Benefits?
+
+Accessibility is often pictured as helping a small group. In fact, the numbers are large. The World Health Organization estimates that roughly **one in six people** in the world lives with a significant disability. And everyone is sometimes limited:
+
+| Kind of barrier | Permanent | Temporary | Situational |
+| :-- | :-- | :-- | :-- |
+| **Seeing** | Blindness, low vision, colour blindness | Eye surgery, a lost pair of glasses | Bright sunlight on a phone |
+| **Hearing** | Deafness, hearing loss | An ear infection | A noisy train, or a silent office |
+| **Movement** | Paralysis, tremor, limb difference | A broken arm | Holding a baby in one arm |
+| **Thinking and learning** | Dyslexia, autism, memory differences | A concussion | Tiredness, stress, a second language |
+
+Features built for disabled people often help **everyone.** Captions help in a noisy café. A keyboard-friendly form is faster for power users. Good contrast helps outdoors. Clear writing helps people reading in a second language. This is called the **curb-cut effect:** ramps cut into pavements for wheelchairs are used by cyclists, parents, and travellers with suitcases every day.
+
+## How People Use the Web
+
+Not everyone uses a mouse and a screen the way you do. Some of the ways people use the web:
+
+| Tool or method | Who uses it | What it needs from your HTML |
+| :-- | :-- | :-- |
+| **Screen reader** | People who are blind or have low vision | Real headings, landmarks, labels, text alternatives |
+| **Keyboard only** | People who cannot use a mouse, and many who prefer not to | Everything reachable and usable with Tab, Enter, Space, and arrows. A visible focus |
+| **Switch devices and voice control** | People with limited movement | Real buttons and links with clear names, so that "click Subscribe" works |
+| **Screen magnifier or zoom** | People with low vision | Layouts that stay usable at 200 percent and more |
+| **High-contrast or custom colours** | People with low vision, light sensitivity, or dyslexia | Do not depend on your own colours alone |
+| **Captions and transcripts** | People who are deaf or hard of hearing, and many others | Text versions of audio and video |
+| **Reduced motion settings** | People with vestibular disorders or motion sensitivity | Respecting `prefers-reduced-motion` |
+| **Simple, predictable pages** | People with cognitive or learning disabilities | Plain language, consistent layout, clear errors |
+
+## The Four Principles: POUR
+
+The Web Content Accessibility Guidelines, known as **WCAG**, are the international standard. They are built on four principles, remembered by the word **POUR**:
+
+| Principle | The question it asks | Example |
+| :-- | :-- | :-- |
+| **P**erceivable | Can people **sense** the content? | Text alternatives for images, captions for video, enough contrast |
+| **O**perable | Can people **use** the controls? | Everything works with a keyboard, no traps, enough time |
+| **U**nderstandable | Can people **make sense** of it? | Clear language, predictable behaviour, helpful error messages |
+| **R**obust | Does it work with **many tools,** now and later? | Valid, semantic HTML, with names, roles, and states that assistive technology can read |
+
+Sort a few real barriers into the four principles:
+
+
+
+
+```html title="pour-examples.html"
+
+
+
+
+Open menu
+
+
+Enter an email address, such as name@example.com.
+
+
+ Send me the newsletter
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+WCAG organises its rules into three **levels:** **A** (the minimum), **AA** (the usual target, and the level most laws and policies ask for), and **AAA** (the highest, and not possible for every kind of content). The current published version is WCAG 2.2, and newer versions may appear, so check the W3C for the latest.
+
+## Contrast: Your First Measurable Rule
+
+One accessibility rule is easy to measure: the **contrast** between text and its background. WCAG asks for at least **4.5 to 1** for normal text at level AA, **3 to 1** for large text, and **3 to 1** for important icons and the borders of form fields. Try the checker below with your own colours:
+
+
+
+
+```css title="styles.css"
+/* A light grey on white often fails. This passes at 4.5 to 1 */
+body {
+ background: #ffffff;
+ color: #595959;
+}
+
+/* Never remove the focus outline without replacing it */
+:focus-visible {
+ outline: 3px solid #1d4ed8;
+ outline-offset: 2px;
+}
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+## The First Rule of ARIA
+
+You will meet **ARIA** later in this section. It is a set of attributes that can add accessibility information to elements. It is powerful, but the most important thing to know is its first rule:
+
+:::tip No ARIA is better than bad ARIA
+**If a native HTML element already does the job, use it.** A real `` is already focusable, works with Enter and Space, and tells assistive technology it is a button. A `` has none of that until you build it yourself. ARIA changes only what is announced. It does **not** add behaviour.
+:::
+
+## What Is in This Section
+
+
+
+## Five Habits to Start Today
+
+1. **Use the right element.** A button for actions, a link for navigation, a heading for a heading.
+2. **Label everything.** Every input, every icon button, every image that carries meaning.
+3. **Try your keyboard.** Put the mouse aside, and use Tab, Shift+Tab, Enter, Space, and Escape.
+4. **Check contrast and zoom.** Enlarge your page to 200 percent, and check the colours.
+5. **Write clearly.** Plain words, short sentences, and error messages that say how to fix the problem.
+
+## Accessibility Checklist for Any Page
+
+- [ ] The page has a `lang` attribute and a unique `
`.
+- [ ] Headings form a logical outline, and landmarks (`header`, `nav`, `main`, `footer`) are used.
+- [ ] Every image has the right `alt`, and decorative images have `alt=""`.
+- [ ] Every form control has a visible ``.
+- [ ] Links make sense out of context, and buttons say what they do.
+- [ ] Everything works with the **keyboard,** and the **focus is always visible.**
+- [ ] Text has enough **contrast,** and information is **never** shown by colour alone.
+- [ ] The page works at **200 percent zoom** and on small screens.
+- [ ] Video has captions, and audio has a transcript.
+- [ ] Animations can be stopped, and respect reduced motion.
+- [ ] Errors are described in text, and are announced.
+
+## Security and Privacy Notes
+
+- **Do not ask people to prove their disability** to use your site, and never store disability information without a clear need and consent. It is sensitive personal data.
+- **Be careful with accessibility detection.** Using a visitor's settings (such as screen reader use, reduced motion, or contrast) to track or profile them can reveal private information. Respond to their preferences in your styles, and do not collect them.
+- **Accessible does not mean unprotected.** CAPTCHAs that rely only on images or sounds shut out many people. Offer an alternative that is both secure and accessible.
+- **Session timeouts** need warnings and a way to extend the time, which is both an accessibility and a security issue.
+- **Do not put sensitive details in labels and error messages** that are read aloud, because others nearby may hear them.
+
+## Global Audience Notes
+
+- **Disability is global,** and so are the tools. Many people in lower-income regions use older phones, slower networks, and free screen readers. Lean, semantic HTML serves them best.
+- **Set `lang`** correctly, and mark passages in other languages, so screen readers pronounce them properly.
+- **Right-to-left languages** need `dir="rtl"` and testing with assistive technology.
+- **Write in plain language,** which helps translators, people reading in a second language, and people with cognitive disabilities.
+- **Laws differ,** but many countries now require accessible websites, for example the Americans with Disabilities Act and Section 508 in the United States, the European Accessibility Act in the European Union, and similar rules elsewhere. They often point to WCAG. This is not legal advice, so check the rules for your region and your audience.
+- **Avoid idioms and cultural shortcuts** in icons and instructions.
+
+## Common Mistakes to Avoid
+
+1. **Treating accessibility as a last-minute extra.**
+2. **Building controls from `div` and `span`** instead of real elements.
+3. **Removing the focus outline.**
+4. **Using colour alone** to show errors, status, or required fields.
+5. **Missing or meaningless alt text,** such as "image" or a file name.
+6. **Placeholder text used as a label.**
+7. **Link text such as "click here".**
+8. **Adding ARIA by default,** and creating wrong or conflicting information.
+9. **Relying only on automatic tools.** They find only part of the problems.
+10. **Never testing with a keyboard or a screen reader.**
+11. **Auto-playing media,** and moving content with no pause.
+12. **Assuming "it looks fine to me" means it is fine.**
+
+## Practice Time
+
+Try these in your editor and your browser:
+
+1. Use the simulator above with each option, and write down what became harder.
+2. Put your mouse away, and complete a form on a site you use, with the keyboard only.
+3. Press Tab through your own page, and note every place where you lose track of the focus.
+4. Check the contrast of three colour pairs on your site, using the checker.
+5. Zoom a page to 200 percent, and note anything that breaks.
+6. Turn on a built-in screen reader for five minutes, and listen to a page you built.
+7. List every image on a page, and write its alt text, or mark it as decorative.
+8. Replace one `div` that acts as a button with a real ``.
+9. Run an automatic accessibility checker (such as the one built into your browser's developer tools), and fix the first three issues.
+10. Bonus: ask a friend who uses assistive technology, if they are happy to help, how they find your site.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What is web accessibility?
+
+Building websites so that as many people as possible can use them, whatever their abilities, devices, or situations.
+
+
+
+
+2. What does POUR stand for?
+
+Perceivable, Operable, Understandable, and Robust: the four principles of the Web Content Accessibility Guidelines.
+
+
+
+
+3. What is the first rule of ARIA?
+
+If a native HTML element already does the job, use it instead of adding ARIA. ARIA changes what is announced, but it does not add behaviour.
+
+
+
+
+4. What contrast does WCAG level AA require for normal text?
+
+At least 4.5 to 1 between the text and its background. Large text and important non-text parts need at least 3 to 1.
+
+
+
+
+5. Why is the keyboard such an important test?
+
+Many people cannot use a mouse, and screen readers, switch devices, and voice control all depend on keyboard-style navigation. If it works with the keyboard, it is likely to work with other tools.
+
+
+
+
+6. What is the curb-cut effect?
+
+Features built for disabled people, such as ramps or captions, often benefit everyone, including cyclists, parents with pushchairs, and people in noisy places.
+
+
+
+## Key Takeaways
+
+- **Accessibility** means building for as many people as possible. Roughly one in six people has a significant disability, and everyone is sometimes limited.
+- WCAG rests on four principles: **Perceivable, Operable, Understandable, Robust.** Level **AA** is the usual target.
+- **Semantic HTML** is the foundation. Use the right element before you reach for ARIA.
+- Test with the **keyboard, zoom, contrast checks, and a screen reader,** and with real people when you can.
+- **Never rely on colour alone,** never remove the focus outline, and always label controls.
+- Protect people's privacy, including information about their disabilities and settings.
+- Plain language, lean pages, and correct `lang` help people everywhere.
+
+Ready to begin? Start with [Introduction to A11y](./accessibility/introduction-to-a11y).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/introduction-to-a11y.mdx b/tutorials/html/13-accessibility/introduction-to-a11y.mdx
index e69de29bb..245dd8b50 100644
--- a/tutorials/html/13-accessibility/introduction-to-a11y.mdx
+++ b/tutorials/html/13-accessibility/introduction-to-a11y.mdx
@@ -0,0 +1,573 @@
+---
+id: html-intro-a11y
+title: "Introduction to Accessibility (a11y)"
+sidebar_label: "Introduction to A11y"
+sidebar_position: 2
+slug: /html/accessibility/introduction-to-a11y
+description: "Learn what web accessibility (a11y) means, why it matters for people, law, business and quality, the common myths, and the one idea that explains everything: the accessibility tree. Includes a live keyboard test of fake and real buttons, a simplified accessibility tree viewer, and a ten-minute self-audit."
+keywords:
+ - what is a11y
+ - web accessibility introduction
+ - accessibility tree
+ - accessible button vs div
+ - accessibility myths
+ - inclusive design
+ - assistive technology
+ - accessibility for beginners
+ - accessible name and role
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, a11y, accessibility-tree, assistive-technology, frontend]
+---
+
+import {useState} from 'react';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+
+export function ButtonTest() {
+ const [focusable, setFocusable] = useState(false);
+ const [keys, setKeys] = useState(false);
+ const [counts, setCounts] = useState({fakeFocus: 0, fakeMouse: 0, fakeKeyboard: 0, realFocus: 0, realMouse: 0, realKeyboard: 0});
+ const bump = (name) => setCounts((previous) => ({...previous, [name]: previous[name] + 1}));
+ const reset = () => setCounts({fakeFocus: 0, fakeMouse: 0, fakeKeyboard: 0, realFocus: 0, realMouse: 0, realKeyboard: 0});
+ const look = {display: "inline-block", background: "#1d4ed8", color: "#fff", padding: "0.5rem 1rem", borderRadius: "6px", border: 0, fontSize: "1em", cursor: "pointer"};
+ const onFakeKey = keys ? (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ bump("fakeKeyboard");
+ }
+ } : undefined;
+ return (
+
+
+ Try to fix the fake button, one step at a time
+ setFocusable(event.target.checked)} /> Add tabindex="0" to the fake button
+ setKeys(event.target.checked)} /> Add Enter and Space handling to the fake button
+
+
Press Tab to move around, then Enter or Space to press. You can use the mouse too.
+
+ bump("fakeFocus")} onClick={() => bump("fakeMouse")} onKeyDown={onFakeKey} style={look}>Fake button (a span) {" "}
+ bump("realFocus")} onClick={(event) => bump(event.detail === 0 ? "realKeyboard" : "realMouse")} style={look}>Real button
+
+
+ What each one really did
+
+ Test Fake button Real button
+
+
+ Reached with Tab {counts.fakeFocus > 0 ? "Yes" : "No"} {counts.realFocus > 0 ? "Yes" : "No"}
+ Pressed with the mouse {counts.fakeMouse} time(s) {counts.realMouse} time(s)
+ Pressed with the keyboard {counts.fakeKeyboard} time(s) {counts.realKeyboard} time(s)
+
+
+
Reset the counts
+
+ {focusable && keys ? "The fake button now works with a keyboard. But a screen reader still does not know it is a button, it has no pressed or disabled state, and you had to write every behaviour yourself." : focusable ? "The fake button can now be reached, but Enter and Space still do nothing. Fixing one part is not enough." : "The fake button cannot be reached with Tab at all, so a keyboard user cannot even find it."}
+
+
+ Both elements are real, and the counts come from real browser events. The real button needed no extra code. Whether the keyboard press counts as a click is decided by the browser, not by this demo.
+
+
+ );
+}
+
+export const treeSamples = {
+ bad: [
+ 'Subscribe
',
+ ' ',
+ ' ',
+ ' ',
+ 'Welcome ',
+ ].join("\n"),
+ good: [
+ "",
+ " The Bread Bin ",
+ ' ',
+ " ",
+ "",
+ " Join us ",
+ " ",
+ ' Email address ',
+ ' ',
+ ' Subscribe ',
+ " ",
+ ' ',
+ ' ',
+ ' Read more ',
+ " ",
+ "",
+ ].join("\n"),
+};
+
+export const isHidden = (el) => el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true" || /display\s*:\s*none/i.test(el.getAttribute("style") || "");
+
+export const textOf = (node) => {
+ if (node.nodeType === 3) return node.textContent;
+ if (node.nodeType !== 1) return "";
+ if (isHidden(node)) return "";
+ if (node.tagName.toLowerCase() === "img") return node.getAttribute("alt") || "";
+ return Array.from(node.childNodes).map(textOf).join(" ");
+};
+
+export const nameOf = (el, doc) => {
+ const tag = el.tagName.toLowerCase();
+ const labelledby = el.getAttribute("aria-labelledby");
+ if (labelledby) {
+ const joined = labelledby.split(/\s+/).map((id) => { const target = doc.getElementById(id); return target ? textOf(target).replace(/\s+/g, " ").trim() : ""; }).filter(Boolean).join(" ");
+ if (joined) return joined;
+ }
+ const label = el.getAttribute("aria-label");
+ if (label && label.trim()) return label.trim();
+ if (tag === "img") return el.getAttribute("alt") || "";
+ if (tag === "input" || tag === "textarea" || tag === "select") {
+ const type = (el.getAttribute("type") || "text").toLowerCase();
+ if (tag === "input" && (type === "submit" || type === "button")) return el.getAttribute("value") || (type === "submit" ? "Submit" : "");
+ let lab = el.id ? doc.querySelector('label[for="' + el.id + '"]') : null;
+ if (!lab) lab = el.closest("label");
+ if (lab) return textOf(lab).replace(/\s+/g, " ").trim();
+ if (el.getAttribute("title")) return el.getAttribute("title");
+ if (el.getAttribute("placeholder")) return "";
+ return "";
+ }
+ const text = textOf(el).replace(/\s+/g, " ").trim();
+ return text || el.getAttribute("title") || "";
+};
+
+export const roleOf = (el) => {
+ const explicit = el.getAttribute("role");
+ if (explicit) return explicit.split(/\s+/)[0];
+ const tag = el.tagName.toLowerCase();
+ const inBody = el.parentElement && el.parentElement.tagName.toLowerCase() === "body";
+ if (/^h[1-6]$/.test(tag)) return "heading";
+ if (tag === "a") return el.hasAttribute("href") ? "link" : "generic";
+ if (tag === "button") return "button";
+ if (tag === "textarea") return "textbox";
+ if (tag === "select") return "combobox";
+ if (tag === "input") {
+ const type = (el.getAttribute("type") || "text").toLowerCase();
+ if (type === "hidden") return "none";
+ if (type === "checkbox") return "checkbox";
+ if (type === "radio") return "radio";
+ if (type === "range") return "slider";
+ if (type === "submit" || type === "button" || type === "reset") return "button";
+ return "textbox";
+ }
+ if (tag === "img") return el.getAttribute("alt") === "" ? "none" : "img";
+ if (tag === "nav") return "navigation";
+ if (tag === "main") return "main";
+ if (tag === "aside") return "complementary";
+ if (tag === "header") return inBody ? "banner" : "generic";
+ if (tag === "footer") return inBody ? "contentinfo" : "generic";
+ if (tag === "ul" || tag === "ol") return "list";
+ if (tag === "li") return "listitem";
+ if (tag === "table") return "table";
+ if (tag === "form") return "form";
+ return "generic";
+};
+
+export const interactiveRoles = ["link", "button", "textbox", "checkbox", "radio", "slider", "combobox"];
+
+export const buildTree = (parent, doc, depth, lines) => {
+ Array.from(parent.childNodes).forEach((node) => {
+ if (node.nodeType === 3) {
+ const text = node.textContent.replace(/\s+/g, " ").trim();
+ if (text) lines.push({depth, text: 'text "' + text + '"', warn: ""});
+ return;
+ }
+ if (node.nodeType !== 1 || isHidden(node)) return;
+ const role = roleOf(node);
+ if (role === "none") return;
+ const tag = node.tagName.toLowerCase();
+ if (role === "generic") {
+ if (node.hasAttribute("onclick")) lines.push({depth, text: "generic (has a click handler)", warn: "Clickable, but it exposes no role, so assistive technology cannot tell it can be pressed. It also cannot be reached with Tab. Use a real button or link."});
+ buildTree(node, doc, node.hasAttribute("onclick") ? depth + 1 : depth, lines);
+ return;
+ }
+ const name = nameOf(node, doc);
+ let line = role;
+ if (role === "heading") line += " level " + tag.charAt(1);
+ if (name) line += ' "' + name + '"';
+ let warn = "";
+ if (interactiveRoles.indexOf(role) !== -1 && !name) warn = "No accessible name. A screen reader user hears only the role" + (role === "textbox" && node.getAttribute("placeholder") ? ", and a placeholder is not a reliable label" : "") + ".";
+ if (role === "img" && !node.hasAttribute("alt")) warn = "No alt attribute. Some screen readers read the file name instead.";
+ lines.push({depth, text: line, warn});
+ if (role === "heading" || role === "link" || role === "button" || role === "img") return;
+ buildTree(node, doc, depth + 1, lines);
+ });
+};
+
+export function TreeViewer() {
+ const [source, setSource] = useState(treeSamples.bad);
+ const doc = new DOMParser().parseFromString("" + source + "", "text/html");
+ const lines = [];
+ buildTree(doc.body, doc, 0, lines);
+ const problems = lines.filter((line) => line.warn).length;
+ return (
+
+
+ setSource(treeSamples.bad)}>Load the faulty sample {" "}
+ setSource(treeSamples.good)}>Load the good sample
+
+
HTML to inspect (edit it freely)
+
setSource(event.target.value)} style={{width: "100%", fontFamily: "monospace", fontSize: "0.85em"}} />
+ What assistive technology is offered (simplified):
+
+ {lines.length === 0 ? (
(nothing)
) : lines.map((line, index) => (
+
+ {line.text}
+ {line.warn ? (
Problem: {line.warn}
) : null}
+
+ ))}
+
+ {problems === 0 ? "No problems found by this simple check. That does not mean the page is fully accessible." : problems + " problem(s) found."}
+
+ This is a teaching simulation. Real browsers follow a longer specification, and screen readers add their own behaviour. To see the real tree, open your browser's developer tools and look for the Accessibility panel.
+
+
+ );
+}
+
+export const auditSteps = [
+ "Put the mouse aside. Press Tab repeatedly. Can you reach every link, button, and field?",
+ "Is the focus always visible? Can you tell where you are?",
+ "Zoom the page to 200 percent. Does anything overlap, disappear, or need sideways scrolling?",
+ "Look at the headings only. Do they tell the story of the page in order?",
+ "Is every image either described, or deliberately left empty because it is decoration?",
+ "Does every form field have a visible label that stays on screen?",
+ "Read each link out of context. Would 'Read more' or 'Click here' make sense in a list of links?",
+ "Is anything explained only by colour? Squint, or use a grayscale filter, to check.",
+ "Does the page have a language set and a clear, unique title?",
+ "Run one automatic checker, and then fix the first problem it finds.",
+];
+
+export function QuickAudit() {
+ const [done, setDone] = useState([]);
+ const toggle = (index) => setDone((previous) => (previous.indexOf(index) === -1 ? previous.concat(index) : previous.filter((item) => item !== index)));
+ const percent = Math.round((done.length / auditSteps.length) * 100);
+ return (
+
+
+ {auditSteps.map((step, index) => (
+
+ toggle(index)} /> {step}
+
+ ))}
+
+
+ {done.length} of {auditSteps.length} steps done ({percent} percent). {done.length === auditSteps.length ? "You have run a basic accessibility audit. Write down what you found, and fix the biggest problem first." : "Open any page you have built, and work through the steps."}
+
+
+ );
+}
+
+Welcome to the start of the accessibility section. Before any code, one question: **what is accessibility, in plain words?**
+
+It is making sure that **people can actually use what you build**. Not "people who see well, hear well, hold a mouse steadily, and read your language at your speed." Just **people.** Accessibility is the quality of being usable by the widest range of people in the widest range of situations.
+
+You will often see it written **a11y.** That is a numeronym: the letter **a**, then **11** letters, then **y** (a-c-c-e-s-s-i-b-i-l-it, 11 letters, then y). It saves typing, and is easy to search for.
+
+
+
+
+## What You Will Learn
+
+- What accessibility means, and **why it matters**
+- The **myths** that stop people from starting
+- How assistive technology sees your page: the **accessibility tree**
+- Why a **real button** beats a fake one
+- How to run a **ten-minute audit** on any page
+- The right **mindset**: build it in, not bolt it on
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+Subscribe
+
+
+Subscribe
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Both look the same. But try pressing **Tab.** One of them is invisible to a keyboard. That single difference is the heart of accessibility: **what the page looks like is not the same as what it is.**
+
+## Why Accessibility Matters
+
+### 1. It is about people
+
+Roughly one in six people in the world lives with a significant disability, according to the World Health Organization. Add people who are temporarily injured, tired, in bright sunlight, using a slow phone, or reading in a second language, and the group becomes almost everyone, at some time. A page that does not work for them is a page that does not work.
+
+### 2. It is often the law
+
+Many countries have laws or policies that require accessible websites, especially for public services, shops, banks, education, and employers. Examples include the Americans with Disabilities Act and Section 508 in the United States, the European Accessibility Act and the EN 301 549 standard in the European Union, and equivalents in many other places. They usually point to the **Web Content Accessibility Guidelines (WCAG).** The details differ, and change over time, so check the rules for your own situation. This is not legal advice.
+
+### 3. It is good for business
+
+Accessible sites reach more customers, convert more visitors, and are cheaper to maintain, because clean, semantic code is easier to change. Many accessibility improvements overlap with search engine optimisation, mobile friendliness, and better overall usability.
+
+### 4. It is a sign of quality
+
+An accessible page is usually a **well-built** page: clear structure, honest HTML, readable text, predictable behaviour. Developers who build accessibly tend to build better in general.
+
+## Myths and Realities
+
+| Myth | Reality |
+| :-- | :-- |
+| "Accessibility is only for blind people." | It covers seeing, hearing, movement, thinking, and learning, plus temporary and situational limits |
+| "Nobody who is disabled uses my site." | You cannot know. Many people do not announce a disability, and inaccessible sites push users away before they ever tell you |
+| "It is expensive and slow." | Building it in from the start costs little. Fixing it later is what costs a lot |
+| "It makes sites ugly." | Accessible sites can be beautiful. Good contrast, clear layout, and readable type are also good design |
+| "An automatic tool can check everything." | Tools find only a portion of problems. They cannot judge whether your alt text is good, or whether the page makes sense |
+| "ARIA fixes any problem." | ARIA adds information. It does not add behaviour. The best fix is usually native HTML |
+| "I will do it after launch." | After launch, it is harder, costlier, and often forgotten |
+| "Text-only versions solve it." | Separate versions are usually out of date, and treat people unequally. Make the main site accessible |
+
+## The Key Idea: The Accessibility Tree
+
+When a browser reads your HTML, it builds the **DOM,** the tree of elements. It then builds a second, smaller tree for assistive technology: the **accessibility tree.** For each important element, it records four things:
+
+| Piece | Question it answers | Example |
+| :-- | :-- | :-- |
+| **Role** | What is it? | button, link, heading, checkbox |
+| **Name** | What is it called? | "Subscribe", "Email address" |
+| **State** | What is its condition? | checked, expanded, disabled, pressed |
+| **Value** | What does it contain? | the text in a field, the position of a slider |
+
+A screen reader does not look at your pixels. It reads this tree. When a native HTML element is used, the browser fills in the role, name, and state **for you.** When you build a fake control from a `div`, the tree shows only "generic", and you must add everything yourself.
+
+Try the viewer below. It is a **simplified** version of the real thing. Start with the faulty sample, and find the problems. Then load the good sample and compare.
+
+
+
+
+```html title="tree-example.html"
+
+Subscribe
+
+
+Email address
+
+
+
+
+
+
+
+```
+
+
+
+
+
+<>
+{() => }
+>
+
+
+
+
+
+You can see the real accessibility tree in your own browser: open developer tools, and look for an **Accessibility** panel, in the Elements area in most browsers.
+
+## The Good News
+
+You do not have to memorise hundreds of rules to start. A small number of habits fix most real-world problems:
+
+1. **Use the right element** for the job.
+2. **Give every control a name.**
+3. **Keep a logical heading structure.**
+4. **Describe meaningful images.**
+5. **Make everything work with a keyboard,** with a visible focus.
+6. **Use enough contrast,** and never rely on colour alone.
+7. **Say what went wrong,** in words, when something fails.
+
+The rest of this section teaches each of these in depth.
+
+## Your First Ten-Minute Audit
+
+Open any page you have built (or a favourite site), and work through these checks. You need no special tools. Tick each one as you go.
+
+
+
+
+```text title="audit-notes.txt"
+Page: Home
+Date: ____
+
+Keyboard: reached everything? ____ focus visible? ____
+Zoom 200%: anything broken? ____
+Headings: logical outline? ____
+Images: alt text OK? ____
+Forms: every label OK? ____
+First fix: ____________________
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+## The Right Mindset
+
+- **Build it in from the start.** Accessibility added at the end is slow and fragile. Accessibility designed in at the beginning is nearly free.
+- **Start with semantic HTML.** It gives you roles, names, keyboard support, and states with no extra work.
+- **Use progressive enhancement.** Make the core content and actions work with plain HTML. Then add styling and scripting on top.
+- **Test early and often,** with a keyboard, with zoom, and with a screen reader.
+- **Listen to disabled people.** Their experience is the real test, and the best teacher.
+- **Aim for progress, not perfection.** No site is perfect. Fix the biggest barriers first, and keep improving.
+- **Share responsibility.** Designers, writers, developers, testers, and managers all affect accessibility.
+
+## Inclusive Design: A Wider View
+
+**Accessibility** is usually about removing barriers, so that disabled people can use a product. **Inclusive design** goes a step further: it asks who might be left out, and designs for that range from the start. A helpful idea comes from the Microsoft inclusive design approach: **a permanent, temporary, or situational limit can look the same.** One arm cannot hold a phone, whether it is broken, whether you were born that way, or whether you are carrying a baby. Designing for one helps all three.
+
+## Good Habits
+
+1. **Use semantic HTML first.**
+2. **Use real buttons and links.**
+3. **Label every control.**
+4. **Check with the keyboard** before you share any page.
+5. **Zoom to 200 percent** as part of every review.
+6. **Write clear, plain content.**
+7. **Test with at least one automatic tool and one manual method.**
+8. **Keep learning.** Accessibility is a skill that grows with practice.
+
+## Accessibility Checklist
+
+- [ ] The page uses real headings, landmarks, lists, buttons, links, and form controls.
+- [ ] Every control and meaningful image has a **name.**
+- [ ] Everything can be done with a **keyboard,** with a visible **focus.**
+- [ ] Text has enough **contrast,** and meaning is never shown by colour alone.
+- [ ] The page works at **200 percent zoom.**
+- [ ] Media has **captions** and transcripts.
+- [ ] Errors and updates are **described in words,** and announced.
+- [ ] The page has a `lang` and a unique ``.
+- [ ] You have tried at least one **screen reader.**
+
+## Security and Privacy Notes
+
+- **Treat disability information as sensitive.** Do not require people to disclose it, and do not store it without a clear purpose and consent.
+- **Do not profile people through their assistive technology.** Detecting screen readers or accessibility settings to track visitors is intrusive. Respond to their preferences with CSS (such as `prefers-reduced-motion`) and do not log them.
+- **Make security features accessible too.** Login steps, CAPTCHAs, and timeouts that exclude people create both a barrier and a risk, because people then look for unsafe workarounds. Offer secure, accessible alternatives, such as passkeys or one-time codes with enough time.
+- **Do not put private details in text that is read aloud,** such as a full card number in an error message, because other people can hear it.
+- **Accessibility overlays and plug-ins that promise instant fixes** often add scripts from third parties and still miss real problems. Be careful with them, and review what they load.
+
+## Global Audience Notes
+
+- **Accessibility needs differ across the world.** Many people use older phones, shared devices, slow networks, and free or built-in assistive tools. Lean, semantic HTML serves them best.
+- **Language matters.** Set `lang`, mark passages in other languages, and write plain text that translates well. Screen readers use `lang` to choose a voice.
+- **Right-to-left languages** need `dir="rtl"`, and testing with real assistive technology.
+- **Standards and laws vary,** but WCAG is used or referenced in many countries, which makes it a good common target.
+- **Different scripts need different text handling,** such as larger sizes for some writing systems. Test with real content in each language you support.
+- **Avoid assumptions** about names, dates, addresses, and abilities. Give people flexible ways to enter and read information.
+
+## Common Mistakes to Avoid
+
+1. **Treating accessibility as a checklist for the last week.**
+2. **Making a fake button from a `div`.**
+3. **Hiding the focus outline.**
+4. **Using placeholder text as the only label.**
+5. **Skipping alt text,** or writing "image of …".
+6. **Using colour alone** to show meaning.
+7. **Trusting an automatic tool as proof** of accessibility.
+8. **Adding ARIA without understanding it.**
+9. **Never trying a keyboard or a screen reader.**
+10. **Assuming your own way of using the web is the only one.**
+11. **Building a separate "accessible version".**
+12. **Giving up because nothing is perfect.**
+
+## Practice Time
+
+Try these in your browser and your editor:
+
+1. Do the ten-minute audit above on a page you built.
+2. Use the keyboard test above, and explain in one sentence why the fake button fails.
+3. Use the tree viewer to find all the problems in the faulty sample, and fix them one by one.
+4. Open your browser's Accessibility panel on a real page, and find a heading, a link, and a button.
+5. Replace one `div onclick` on your own site with a real button.
+6. Turn on a screen reader (such as the one built into your phone) for five minutes, and use a site you know.
+7. Write a one-paragraph summary of why accessibility matters, for a friend who is a business owner.
+8. List five situations when you have been temporarily limited (for example, bright light, noise, one hand free).
+9. Choose one myth from the table above, and write a short reply to it.
+10. Bonus: write a one-page accessibility promise for your next project, listing the habits you will keep.
+
+
+
+
+## Quick Recap
+
+Click a question to check your answer.
+
+
+1. What does a11y mean?
+
+It is short for "accessibility": the letter a, then 11 letters, then y.
+
+
+
+
+2. Who benefits from accessibility?
+
+People with permanent disabilities, people with temporary limits (such as a broken arm), people in difficult situations (such as bright sunlight or a noisy room), and often everyone else too.
+
+
+
+
+3. What is the accessibility tree?
+
+A tree the browser builds from your HTML for assistive technology. For each important element, it records a role, a name, a state, and a value.
+
+
+
+
+4. Why is a real button better than a div that looks like a button?
+
+A real button is reachable with Tab, works with Enter and Space, and is announced as a button, all without extra code. A div has none of that unless you build every part yourself.
+
+
+
+
+5. Can an automatic tool tell you if your site is accessible?
+
+No. Tools find only some problems. You also need manual checks, such as keyboard and screen reader testing, and ideally feedback from disabled users.
+
+
+
+
+6. When should you start thinking about accessibility?
+
+At the very beginning of a project. Building it in from the start is far cheaper and more reliable than fixing it later.
+
+
+
+## Key Takeaways
+
+- **Accessibility (a11y)** means making things usable by the widest range of people, in the widest range of situations.
+- It matters for **people, the law, business, and quality.**
+- Browsers build an **accessibility tree** of **roles, names, states, and values** from your HTML. **Native elements fill it in for you.**
+- A fake control from a `div` fails the keyboard, and tells assistive technology nothing.
+- Start with a few habits: the **right element, a name for everything, logical headings, described images, keyboard support, good contrast, and clear errors.**
+- **Build it in early,** test with real methods, and listen to real people.
+- Be careful with the privacy of disability information, and with shortcuts that promise instant accessibility.
+
+Next, learn the standard that most of the world follows: [WCAG Basics](./wcag-basics).
\ No newline at end of file
diff --git a/tutorials/html/13-accessibility/keyboard-accessibility.mdx b/tutorials/html/13-accessibility/keyboard-accessibility.mdx
index e69de29bb..b7382ac77 100644
--- a/tutorials/html/13-accessibility/keyboard-accessibility.mdx
+++ b/tutorials/html/13-accessibility/keyboard-accessibility.mdx
@@ -0,0 +1,572 @@
+---
+id: html-keyboard-accessibility
+title: "Keyboard Accessibility"
+sidebar_label: "Keyboard Accessibility"
+sidebar_position: 6
+slug: /html/accessibility/keyboard-accessibility
+description: "Learn how to make every part of a web page work with a keyboard: which elements are focusable, how tabindex and focus order work, how to style focus with :focus-visible, how to avoid keyboard traps, and how arrow keys and roving tabindex work in custom widgets. Includes a live focus-order lab, a focus-style test, and a roving toolbar."
+keywords:
+ - keyboard accessibility
+ - tabindex
+ - focus order
+ - focus-visible
+ - keyboard trap
+ - roving tabindex
+ - WCAG 2.1.1 keyboard
+ - focus indicator
+ - tab key navigation
+ - learn HTML
+ - CodeHarborHub HTML
+tags: [html, web-development, beginner, accessibility, keyboard, focus, tabindex, a11y, frontend]
+---
+
+import {useState, useRef} from 'react';
+
+export const tabItems = ["Home", "Recipes", "Shop", "Blog", "Contact"];
+
+export function TabOrderLab() {
+ const [positive, setPositive] = useState(false);
+ const [reverse, setReverse] = useState(false);
+ const [skipSecond, setSkipSecond] = useState(false);
+ const [log, setLog] = useState([]);
+ const startRef = useRef(null);
+ const record = (label) => (event) => {
+ const left = event.currentTarget.getBoundingClientRect().left;
+ setLog((previous) => {
+ const last = previous[previous.length - 1];
+ const backwards = Boolean(last) && left < last.left - 1;
+ return previous.concat({label, left, backwards}).slice(-12);
+ });
+ };
+ const tabIndexFor = (index) => {
+ if (index === 3 && positive) return 1;
+ if (index === 1 && skipSecond) return -1;
+ return undefined;
+ };
+ const jumps = log.filter((entry) => entry.backwards).length;
+ return (
+
+
+ Change the page, and watch the focus order
+ { setPositive(event.target.checked); setLog([]); }} /> Give "Blog" tabindex="1" (a positive value)
+ { setReverse(event.target.checked); setLog([]); }} /> Reverse the visual order with CSS (flex-direction: row-reverse)
+ { setSkipSecond(event.target.checked); setLog([]); }} /> Give "Recipes" tabindex="-1"
+
+
+ setLog([])}>Start here Click it (or press Enter), then press Tab repeatedly.
+
+
+ {tabItems.map((label, index) => (
+ {label}
+ ))}
+
+
Focus log (most recent last):
+
+ {log.length === 0 ? ((press Tab after clicking Start here) ) : log.map((entry, index) => ({entry.label}{entry.backwards ? " (the focus jumped backwards on screen)" : ""} ))}
+
+
+ {jumps > 0 ? "Focus moved against the reading direction " + jumps + " time(s). A keyboard user would feel lost." : positive ? "A positive tabindex takes an element out of the natural order. Browsers visit it before everything with tabindex 0, so it is reached first when Tab starts at the top of the page, not here in the row." : skipSecond ? "A tabindex of -1 removes Recipes from the Tab order. Keyboard users can no longer reach it." : reverse ? "Press Tab through the row to see the jumps." : "With no changes, focus follows the order in the code, which is also the visual order."}
+
+
+ This is real browser behaviour, measured when each button gets focus. The exact starting point after clicking can differ a little between browsers.
+
+
+ );
+}
+
+export const focusStyles = {
+ default: "",
+ none: ".fs-none a:focus, .fs-none button:focus, .fs-none input:focus, .fs-none select:focus { outline: none; }",
+ custom: ".fs-custom a:focus-visible, .fs-custom button:focus-visible, .fs-custom input:focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; box-shadow: 0 0 0 5px #fde68a; }",
+ focusAlways: ".fs-always a:focus, .fs-always button:focus, .fs-always input:focus { outline: 3px solid #b91c1c; outline-offset: 2px; }",
+};
+
+export function FocusStyleLab() {
+ const [mode, setMode] = useState("default");
+ const className = mode === "none" ? "fs-none" : mode === "custom" ? "fs-custom" : mode === "focusAlways" ? "fs-always" : "";
+ const css = focusStyles.none + focusStyles.custom + focusStyles.focusAlways;
+ const options = [
+ {key: "default", label: "Browser default focus ring"},
+ {key: "none", label: "outline: none (do not do this)"},
+ {key: "custom", label: "Custom ring with :focus-visible (recommended)"},
+ {key: "focusAlways", label: "Custom ring with :focus (also appears on mouse clicks)"},
+ ];
+ return (
+
+
+
+ Focus style
+ {options.map((option) => (
+
+ setMode(option.key)} /> {option.label}
+
+ ))}
+
+
Press Tab to move through the controls. Then click them with the mouse, and compare.
+
+
+ {mode === "none" ? "With the outline removed, a keyboard user cannot see where they are. This fails WCAG 2.4.7 (Focus Visible)." : mode === "custom" ? "The ring shows when you use the keyboard. Many browsers do not show it when you click a button with the mouse, which is what :focus-visible is for." : mode === "focusAlways" ? "The ring shows for every kind of focus, including mouse clicks. It is accessible, but some designers find it noisy." : "Every browser draws its own default ring. It is usually acceptable, but check it on your colours."}
+
+
+ These are real focus styles on real elements, so what you see is what a visitor would see. Which clicks show a ring under :focus-visible depends a little on the browser and the element.
+
+
+ );
+}
+
+export const toolbarItems = ["Bold", "Italic", "Underline", "Link"];
+
+export function RovingToolbar() {
+ const [roving, setRoving] = useState(true);
+ const [active, setActive] = useState(0);
+ const [pressed, setPressed] = useState([]);
+ const [stops, setStops] = useState(0);
+ const [keys, setKeys] = useState([]);
+ const refs = useRef([]);
+ const move = (next) => {
+ const target = (next + toolbarItems.length) % toolbarItems.length;
+ setActive(target);
+ if (refs.current[target]) refs.current[target].focus();
+ };
+ const onKeyDown = (index) => (event) => {
+ setKeys((previous) => previous.concat(event.key === " " ? "Space" : event.key).slice(-6));
+ if (!roving) return;
+ if (event.key === "ArrowRight") { event.preventDefault(); move(index + 1); }
+ if (event.key === "ArrowLeft") { event.preventDefault(); move(index - 1); }
+ if (event.key === "Home") { event.preventDefault(); move(0); }
+ if (event.key === "End") { event.preventDefault(); move(toolbarItems.length - 1); }
+ };
+ const toggle = (label) => setPressed((previous) => (previous.indexOf(label) === -1 ? previous.concat(label) : previous.filter((item) => item !== label)));
+ return (
+
+
+ { setRoving(event.target.checked); setStops(0); setKeys([]); }} /> Use the roving tabindex pattern
+
+
+ setStops(0)}>Before the toolbar Press Tab from here, and watch the count. {roving ? "Once inside, use the arrow keys, Home, and End." : "Every button needs its own Tab press."}
+
+
+ {toolbarItems.map((label, index) => (
+ { refs.current[index] = element; }} type="button" aria-pressed={pressed.indexOf(label) !== -1} tabIndex={roving ? (index === active ? 0 : -1) : 0} onFocus={() => { setActive(index); setStops((previous) => previous + 1); }} onKeyDown={onKeyDown(index)} onClick={() => toggle(label)} style={{padding: "0.4rem 0.8rem", fontWeight: pressed.indexOf(label) !== -1 ? 700 : 400, background: pressed.indexOf(label) !== -1 ? "#dbeafe" : undefined, color: pressed.indexOf(label) !== -1 ? "#1e293b" : undefined}}>{label}
+ ))}
+ (pressed: {pressed.length === 0 ? "none" : pressed.join(", ")})
+
+
After the toolbar
+
Focus moves counted since you left the first button: {stops} . Last keys: {keys.length === 0 ? "(none)" : keys.join(", ")}.
+
+ In the roving pattern, only one button is in the Tab order at a time (the active one has tabindex 0, and the others have -1). Tab goes in once and out once, and the arrow keys move inside. This is the pattern recommended for toolbars, tabs, and menus.
+
+
+ );
+}
+
+A mouse is just one way to use a computer. Many people never touch one, or cannot. People who are blind use a keyboard (or touch gestures) with a screen reader. People with tremors, paralysis, or repetitive strain injuries may use a keyboard, a switch, a mouth stick, or voice commands that behave like a keyboard. Many power users simply find the keyboard **faster.**
+
+That is why **keyboard accessibility** is one of the most important rules in WCAG (2.1.1), and one of the easiest to test. **If everything works with the keyboard, it will usually work with many other assistive tools too.** And if it does not, it is broken for a huge group of people.
+
+
+
+
+## What You Will Learn
+
+- The **keys** people use to browse
+- Which elements are **focusable** by default
+- How **`tabindex`** works, and which values to use and avoid
+- Keeping a **logical focus order**
+- Making focus **clearly visible,** with `:focus-visible`
+- Avoiding **keyboard traps**
+- How **arrow keys** and **roving tabindex** work in custom widgets
+- How to **test** your own pages
+
+## The Big Idea
+
+
+
+
+```html title="index.html"
+
+Recipes
+
+Subscribe
+
+Email address
+
+
+
+Send me the newsletter
+
+
+ Sourdough
+ Rye
+
+```
+
+
+
+
+
+<>
+
+>
+
+
+
+
+
+Use the lab as a real test. Start with everything off, and Tab through. Then turn on each option, one at a time, and read the log.
+
+## The Keys That Matter
+
+| Key | What it does |
+| :-- | :-- |
+| Tab | Moves focus to the next focusable element |
+| Shift + Tab | Moves focus to the previous one |
+| Enter | Follows a link, presses a button, submits a form |
+| Space | Presses a button, ticks a checkbox, scrolls the page |
+| Arrow keys | Move within a group: radio buttons, a select list, tabs, menus, sliders |
+| Esc | Closes a dialog, menu, or pop-up |
+| Home and End | Jump to the first or last item in a list or widget |
+| Page Up and Page Down | Scroll, or move in large steps |
+
+Real HTML elements already follow these rules. That is the biggest reason to use them.
+
+## What Can Receive Focus?
+
+Some elements are **focusable by default.** They are in the Tab order automatically:
+
+| Element | Notes |
+| :-- | :-- |
+| `` | Only links **with an `href`** |
+| `` | Unless `disabled` |
+| ` `, ``, `` | Unless `disabled`. `type="hidden"` is not focusable |
+| `` | The toggle of a `` element |
+| `