Mert Özen Further With Every Line
Frontend HTML5 and Semantic HTML — #13

Global Attributes, Link Types and Inline SVG in HTML: data-*, hidden, contenteditable, spellcheck, translate, download, mailto/tel and Accessible Icons

Mert Özen Nov 15, 2026 17 min 5 views
Global Attributes, Link Types and Inline SVG in HTML: data-*, hidden, contenteditable, spellcheck, translate, download, mailto/tel and Accessible Icons

contenteditable is not a form control: plaintext-only turns rich text into plain text, but the note never reaches the server with the form, so the right element is a textarea. In this post we measure data-*, hidden="until-found", spellcheck, autocapitalize and translate in Chromium and WebKit, and show tel:, mailto:, download's same-origin limit, accessible SVG icons and the <svg hidden> pitfall on Bookstore.

Version note: This post follows the HTML Living Standard as updated on 14 November 2026 and the November 2026 state of Baseline; support data comes from the web-features 3.46 package, and the examples were tested with Chrome 158, WebKit 27.2 (Playwright 1.65), Nu Html Checker 26.11.14 and html-validate 11.21.

Guess First

Three pieces from the book detail page and the admin panel of Bookstore, the online bookstore used as the shared example across this site's series:

<button type="button" title="Add to wishlist" id="wishlist-old">
    <svg viewBox="0 0 24 24" width="24" height="24">
        <title>Heart</title>
        <path d="M12 21 3.5 12.5a5 5 0 0 1 8.5-6 5 5 0 0 1 8.5 6z"/>
    </svg>
</button>

<p><a href="/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Download invoice (PDF)</a></p>
<p><a href="http://127.0.0.1:52114/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Invoice from files host</a></p>

<div id="note" contenteditable="plaintext-only" role="textbox" aria-multiline="true"
     aria-labelledby="note-label" data-field="note" spellcheck="true"></div>

Three questions. What does a screen reader call the first button: "Add to wishlist" or "Heart"? The second invoice link fetches the invoice from a separate origin (here, another port on the same machine); both links carry download, so which one downloads under which name? Finally, the note field sits inside a form; when you press "Save", does the note reach the server?

In "What Arrived in HTML 2023–2026" we closed the series with new features. This extension post collects the pieces we put on every element but never stopped to look at: global attributes, link types, and accessible markup for inline SVG icons.

data-*: Carrying Data for Scripts

The book page's article carries two values the cart script needs: the ISBN and the stock count.

<article data-isbn="9780547928227" data-stock-count="12">
    <h1 translate="no">The Hobbit</h1>
    <p>by <span translate="no">J. R. R. Tolkien</span>, ISBN <span class="isbn" translate="no">978-0547928227</span></p>

Every attribute that starts with data- is valid, and the browser gives it no meaning; it is space reserved for the page's own scripts and CSS. In JavaScript you read it through dataset: the prefix is dropped and the letter after each hyphen is uppercased, so data-stock-count becomes dataset.stockCount. Don't use uppercase letters in the names. A script that measures this post's global attributes in one go:

const pw = require('playwright');
const start = require('./server');

(async () => {
  const servers = start();
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    const page = await browser.newPage();
    await page.goto('http://127.0.0.1:52113/book.html');
    const book = await page.evaluate(() => {
      const article = document.querySelector('article');
      // data-stock-count → dataset.stockCount, the value is always a string
      const { isbn, stockCount } = article.dataset;
      return `dataset: isbn=${isbn} stockCount=${JSON.stringify(stockCount)}\n  ` +
        `translate: h1=${document.querySelector('h1').translate} isbn=${document.querySelector('.isbn').translate} aside=${document.querySelector('aside').translate}\n  ` +
        `draggable: a=${document.querySelector('aside a').draggable} p=${document.querySelector('aside p').draggable}`;
    });
    await page.goto('http://127.0.0.1:52113/admin-note.html');
    const form = await page.evaluate(() =>
      ['note', 'recipient', 'coupon', 'courier'].map(id => {
        const el = document.getElementById(id);
        return `${id}: autocapitalize="${el.autocapitalize}" spellcheck=${el.spellcheck}`;
      }).join('\n  '));
    console.log(`${name} ${browser.version()}\n  ${book}\n  ${form}`);
    await browser.close();
  }
  servers.forEach(s => s.close());
})();
chromium 158.0.8127.18
  dataset: isbn=9780547928227 stockCount="12"
  translate: h1=false isbn=false aside=true
  draggable: a=true p=false
  note: autocapitalize="" spellcheck=true
  recipient: autocapitalize="words" spellcheck=true
  coupon: autocapitalize="characters" spellcheck=false
  courier: autocapitalize="none" spellcheck=false
webkit 27.2
  dataset: isbn=9780547928227 stockCount="12"
  translate: h1=false isbn=false aside=true
  draggable: a=true p=false
  note: autocapitalize="undefined" spellcheck=true
  recipient: autocapitalize="undefined" spellcheck=true
  coupon: autocapitalize="undefined" spellcheck=false
  courier: autocapitalize="undefined" spellcheck=false

On the first line stockCount comes back in quotes: the value is always a string. "12" > "9" is false, and converting to a number is your job. More on dataset in the "Advanced DOM in JavaScript" post of the JavaScript Orta series.

data-* is only for data the script uses; anything the user needs to know belongs in the text, because screen readers don't read data-*. If you need a role or a state, that is what native elements and ARIA are for.

The third line is draggable: links and images are draggable by default, paragraphs are not. draggable="true" turns dragging on, but you write the drop logic and the keyboard equivalent; every drag-and-drop interface needs a second path that works with buttons.

In "Native Interactive Elements 1" we opened hidden="until-found" with a fragment pointing at an id. Its real purpose is find-in-page: the content stays hidden but Ctrl+F finds it. According to the Living Standard (14 November 2026), when find-in-page or a fragment targets such content, the browser first fires beforematch and then removes the attribute; if the match is inside a closed details, it opens that too. According to MDN's hidden page (10 November 2026), browsers implement this with content-visibility: hidden, so an element whose display is inline or none is not revealed. On the table of contents and the details:

const pw = require('playwright');
const start = require('./server');

const base = 'http://127.0.0.1:52113/book.html';

(async () => {
  const servers = start();
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    const page = await browser.newPage();
    // record beforematch events on every page load
    await page.addInitScript(() => {
      window.matches = [];
      document.addEventListener('beforematch', e => matches.push(e.target.id || e.target.tagName), true);
    });
    const state = () => page.evaluate(() =>
      `toc hidden=${JSON.stringify(document.getElementById('toc').getAttribute('hidden'))} ` +
      `details open=${document.querySelector('details').open} beforematch=[${matches}]`);

    await page.goto(base);
    console.log(`${name} load:          ${await state()}`);
    await page.evaluate(() => window.find('Riddles in the Dark'));
    console.log(`${name} window.find:   ${await state()}`);
    await page.goto('about:blank');
    await page.goto(base + '#:~:text=Riddles%20in%20the%20Dark');
    console.log(`${name} text fragment: ${await state()}`);
    await page.goto('about:blank');
    await page.goto(base + '#:~:text=original%20maps');
    console.log(`${name} details text:  ${await state()}`);
    await browser.close();
  }
  servers.forEach(s => s.close());
})();
chromium load:          toc hidden="until-found" details open=false beforematch=[]
chromium window.find:   toc hidden="until-found" details open=false beforematch=[]
chromium text fragment: toc hidden=null details open=false beforematch=[toc]
chromium details text:  toc hidden="until-found" details open=true beforematch=[]
webkit load:          toc hidden="until-found" details open=false beforematch=[]
webkit window.find:   toc hidden="until-found" details open=false beforematch=[]
webkit text fragment: toc hidden=null details open=false beforematch=[toc]
webkit details text:  toc hidden="until-found" details open=true beforematch=[]

Real Ctrl+F can't be triggered from a script; the second line shows that window.find() is no substitute: this non-standard method searches the text but doesn't reveal hidden content. The text fragment on the third line (#:~:text=, the format behind "scroll to text" links in search results) did open the list, and beforematch fired. On the last line the match was inside the details: the details opened but no event fired, because the event is only for until-found. In Chromium a text fragment wasn't processed when only the fragment changed on the same page; that is why the script has the about:blank steps.

The WebKit lines reveal everything too, but the warning from "What Arrived in HTML 2023–2026" applies here as well: Playwright's WebKit build tracks the development branch. According to web-features, hidden="until-found" is in Chrome 102 and Firefox 148 and not in any released Safari; it is not Baseline. In Safari the content stays closed like plain hidden, so use it only where the content can also be reached another way. How plain hidden differs from aria-hidden and inert is in "Accessible Markup Basics".

contenteditable and plaintext-only: The Note Never Joins the Form

The admin panel form that adds a short note to an order:

<form method="post" action="/admin/orders/1042/notes" autocapitalize="words">
    <p id="note-label">Internal note</p>
    <div id="note" contenteditable="plaintext-only" role="textbox" aria-multiline="true"
         aria-labelledby="note-label" data-field="note" spellcheck="true"></div>
    <label for="recipient">Recipient name</label>
    <input type="text" id="recipient" name="recipient" autocomplete="off">
    <label for="coupon">Coupon code</label>
    <input type="text" id="coupon" name="coupon" autocapitalize="characters" spellcheck="false" autocomplete="off">
    <label for="courier">Courier reference</label>
    <textarea id="courier" name="courier" spellcheck="false" autocapitalize="none"></textarea>
    <button type="submit">Save</button>
</form>

contenteditable="plaintext-only" became Baseline newly available in March 2025 with Firefox 136; Chrome and Safari had supported it for years. The difference from true is that only plain text can be edited. A script that tries the same paste and Ctrl+B with both values, then submits the form:

const { chromium } = require('playwright');
const start = require('./server');

(async () => {
  const servers = start();
  const browser = await chromium.launch();
  const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
  const page = await context.newPage();
  await page.goto('http://127.0.0.1:52113/admin-note.html');

  // the same paste and Ctrl+B, with two contenteditable values
  for (const mode of ['plaintext-only', 'true']) {
    await page.evaluate(async m => {
      const note = document.getElementById('note');
      note.contentEditable = m;
      note.textContent = '';
      await navigator.clipboard.write([new ClipboardItem({
        'text/html': new Blob(['<b>Call</b> before <i>17:00</i>'], { type: 'text/html' }),
        'text/plain': new Blob(['Call before 17:00'], { type: 'text/plain' }),
      })]);
    }, mode);
    await page.locator('#note').click();
    await page.keyboard.press('ControlOrMeta+v');
    await page.keyboard.press('ControlOrMeta+b');
    await page.keyboard.type(' urgent');
    console.log(`${mode.padEnd(14)} innerHTML: ${await page.locator('#note').innerHTML()}`);
  }

  // which fields reach the server when the form is submitted?
  await page.fill('#recipient', 'Ada Lovelace');
  await page.fill('#coupon', 'HOBBIT10');
  await page.getByRole('button', { name: 'Save' }).click();
  await page.waitForLoadState();

  // fixed page: the note is a textarea
  await page.goto('http://127.0.0.1:52113/admin-note-fixed.html');
  await page.fill('#note', 'Call before 17:00');
  await page.getByRole('button', { name: 'Save' }).click();
  await page.waitForLoadState();
  await browser.close();
  servers.forEach(s => s.close());
})();
plaintext-only innerHTML: Call before 17:00 urgent
true           innerHTML: <b>Call</b> before <i>17:00<b>&nbsp;urgent</b></i>
server: POST /admin/orders/1042/notes recipient=Ada+Lovelace&coupon=HOBBIT10&courier=
server: POST /admin/orders/1042/notes note=Call+before+17%3A00&recipient=&coupon=&courier=

The rich text on the clipboard was pasted as plain text with plaintext-only, and Ctrl+B did nothing; with true the tags came along and Ctrl+B wrapped the new word in a b. If you don't want HTML from the user, this is the right value.

The third line answers the guess: the note is missing from the body sent to the server. An element with contenteditable is not a form control; it has no name, isn't submitted with the form, isn't validated by required or maxlength, and isn't cleared on reset. The right element is a textarea:

<label for="note">Internal note</label>
<textarea id="note" name="note" maxlength="500" autocapitalize="sentences"></textarea>

On the script's last line the note is in the body. For a textarea that grows with its content, field-sizing: content is in "HTML Forms 2". html-validate points to the same place, but look at its first line:

site/admin-note.html
  12:45  error  Attribute "contenteditable" has invalid value "plaintext-only"  attribute-allowed-values
  12:61  error  Prefer to use the native <textarea> element                     prefer-native-element

The metadata in version 11.21 doesn't know plaintext-only yet; Nu Html Checker accepted it. The second rule is the right advice. Where contenteditable is really needed is rich text editors, and for those use a mature library instead of writing one from scratch.

spellcheck, autocapitalize and translate: Hints for the Browser

These three attributes only give hints; the rest of the attributes.js output shows them. I put spellcheck="false" on the coupon code and the courier reference, fields where the spellchecker's red underline means nothing. MDN's spellcheck page (3 July 2026) adds a security note: some browsers' enhanced spellchecking can send the field's content to a third party, so turn it off on fields with sensitive information. The value is inherited from the parent element.

autocapitalize sets the capitalization behaviour of the on-screen keyboard and voice input: none, sentences, words, characters. It doesn't affect a physical keyboard and is ignored on email, url and password inputs. I used characters for the coupon code. The form's words passed to the recipient name, which has no value of its own; the fields with their own value overrode the form's. That is the order the Living Standard defines; MDN's page (17 April 2026) says the form's value takes precedence, and Chromium behaves like the spec. The note with contenteditable got nothing from the form ("") because it isn't a form control. The undefined in WebKit comes from Playwright's macOS WebKit not knowing this property at all, like desktop Safari: according to web-features, autocapitalize is in Chrome, Firefox and iOS Safari but not in desktop Safari; it isn't Baseline, but it is a harmless hint. autocorrect, from the same family, was in "HTML Forms 1".

translate="no" marks the parts the browser or a translation tool must leave alone when translating the page. I gave it to the title, the author and the ISBN so that a user reading the page through machine translation doesn't see the book title translated. The value is inherited: in the output the h1 and the ISBN are false, the aside is true. The spec also counts attributes such as title, alt and download as translatable; translate="no" protects them too. translate has been Baseline since March 2023 and widely available since September 2025.

This section is based on the official documentation.

The book page's side column gives three ways to reach customer service and two invoice links:

<aside aria-labelledby="help-title">
    <h2 id="help-title">Need help with an order?</h2>
    <p>
        <a href="tel:+12025550143">
            <svg aria-hidden="true" width="16" height="16"><use href="#icon-phone"/></svg>
            +1&nbsp;202&nbsp;555&nbsp;0143
        </a>
    </p>
    <p><a href="mailto:support@example.com?subject=Order%201042">support@example.com</a></p>
    <p><a href="sms:+12025550143?body=Order%201042">Text us</a></p>
    <p><a href="/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Download invoice (PDF)</a></p>
    <p><a href="http://127.0.0.1:52114/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Invoice from files host</a></p>
    <p><a href="http://127.0.0.1:52114/orders/1042/invoice.pdf?attachment">Invoice from files host (attachment)</a></p>
</aside>

tel: opens the dialler on a phone and, on a desktop, the application assigned to the job (if any). Write the number in href in international form, with + and no spaces; the visible text can use any format. The number in the example comes from the 555-0100–555-0199 range reserved for fictional use. The &nbsp; characters in the visible number are html-validate's tel-non-breaking suggestion, so the number doesn't break at the end of a line.

mailto: can prefill the subject and body with ?subject= and &body=; the values must be URL-encoded (%20). On a desktop with no mail client configured the link seems to do nothing; showing the address as the link text at least lets the user copy it. The body parameter of sms: differs between platforms, and on desktops it usually has no handler.

#fragment goes to an id on the same page; the text fragment above is its text-based form. The rule from "Text Semantics" applies here too: taken out of context, link text should still say where it goes. "Text us" and the email address do that, "Click here" doesn't.

This section is based on the official documentation.

download, rel and ping

A small server that opens two origins for the invoices: the store on port 52113, the invoice server on 52114. When asked with ?attachment, the second one also sends a Content-Disposition: attachment header:

const http = require('http');
const fs = require('fs');
const path = require('path');

const types = { '.html': 'text/html; charset=utf-8', '.pdf': 'application/pdf' };

// two separate origins: store pages and invoice files
function serve(root, port, extraHeaders = () => ({})) {
  return http.createServer((req, res) => {
    if (req.method === 'POST') {
      let body = '';
      req.on('data', c => (body += c));
      req.on('end', () => {
        console.log(`server: POST ${req.url} ${body}`);
        res.end('saved');
      });
      return;
    }
    const url = new URL(req.url, `http://${req.headers.host}`);
    const file = path.join(root, url.pathname === '/' ? 'book.html' : url.pathname);
    if (!file.startsWith(root) || !fs.existsSync(file)) {
      res.statusCode = 404;
      return res.end('not found');
    }
    res.writeHead(200, { 'Content-Type': types[path.extname(file)] ?? 'application/octet-stream', ...extraHeaders(url) });
    res.end(fs.readFileSync(file));
  }).listen(port, '127.0.0.1');
}

module.exports = () => [
  serve(path.join(__dirname, 'site'), 52113),
  serve(path.join(__dirname, 'files'), 52114, url =>
    url.searchParams.has('attachment') ? { 'Content-Disposition': 'attachment; filename="order-1042-invoice.pdf"' } : {}),
];

A script that clicks the three invoice links in turn:

const { chromium } = require('playwright');
const start = require('./server');

(async () => {
  const servers = start();
  // headless shell has no PDF viewer; navigating to a PDF turns into a download too
  const browser = await chromium.launch({ channel: 'chromium' });
  console.log(`chromium ${browser.version()}`);
  const page = await browser.newPage({ acceptDownloads: true });

  for (const name of ['Download invoice (PDF)', 'Invoice from files host', 'Invoice from files host (attachment)']) {
    await page.goto('http://127.0.0.1:52113/book.html');
    const download = page.waitForEvent('download', { timeout: 3000 }).catch(() => null);
    await page.getByRole('link', { name, exact: true }).click();
    const file = await download;
    console.log(`${name.padEnd(38)} -> ${file ? `download "${file.suggestedFilename()}"` : `navigation ${page.url()}`}`);
  }
  await browser.close();
  servers.forEach(s => s.close());
})();
chromium 158.0.8127.18
Download invoice (PDF)                 -> download "bookstore-order-1042.pdf"
Invoice from files host                -> navigation http://127.0.0.1:52114/orders/1042/invoice.pdf
Invoice from files host (attachment)   -> download "order-1042-invoice.pdf"

That is the second answer to the guess. The same-origin link downloaded under the name in download. The cross-origin link carried the same attribute but became a navigation, and the browser opened the PDF: download is only honoured for same-origin, blob: and data: URLs. The way to force a download from another origin is the server: on the third line Content-Disposition: attachment made the file download, and the name came from the header's filename. MDN's a page (28 October 2026) also notes that the name in the header overrides the one in the attribute. If invoices come from a CDN, set up downloads there, with the header. The "(PDF)" in the link text is MDN's advice too: users should know a file will download before they click.

Playwright's default headless shell has no PDF viewer, and there the second link downloaded as invoice.pdf; that is why I used the full Chromium build (channel: 'chromium').

noopener and noreferrer were in "Text Semantics". The other rel values are hints: nofollow says the linked page isn't vouched for, ugc that the link comes from user content such as book reviews, sponsored that it is paid, external that it goes to another site. Their SEO effects are in the Teknik SEO series.

When a link is followed, ping sends a background POST whose body is PING to the listed URLs; the spec calls it "hyperlink auditing", in other words click tracking. Firefox doesn't have it, it isn't Baseline, and the spec lets browsers skip the request entirely. Leave click measurement to your analytics tool or the server. This section is based on the official documentation.

Inline SVG and Accessible Icons

The icons on the book page come from a single sprite: each icon is defined once as a symbol and pulled in with use where it is needed:

<svg aria-hidden="true" style="display: none">
    <symbol id="icon-heart" viewBox="0 0 24 24">
        <path d="M12 21 3.5 12.5a5 5 0 0 1 8.5-6 5 5 0 0 1 8.5 6z"/>
    </symbol>
    <symbol id="icon-phone" viewBox="0 0 24 24">
        <path d="M6 2h4l2 5-3 2a11 11 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A18 18 0 0 1 4 4a2 2 0 0 1 2-2z"/>
    </symbol>
    <symbol id="icon-check" viewBox="0 0 24 24">
        <path d="M4 12l5 5L20 6" fill="none" stroke="currentColor" stroke-width="3"/>
    </symbol>
</svg>

<p>
    Availability:
    <svg role="img" aria-label="In stock" width="16" height="16"><use href="#icon-check"/></svg>
</p>
<button type="button" aria-label="Add to wishlist" id="wishlist">
    <svg aria-hidden="true" width="24" height="24"><use href="#icon-heart"/></svg>
</button>

In the first version I hid the sprite with <svg hidden>. The first two lines of the script below for that version:

sprite box: 300x150 display=inline
image ""

The sprite wasn't visible, but it took up an empty 300×150 pixel area at the top of the page, and Chromium had put it in the tree as an unnamed image; in WebKit the box was the same. The reason is that hidden is an HTML attribute: the browser's default style that hides it only applies to HTML elements, and svg lives in the SVG namespace. On the page fixed with aria-hidden="true" and display: none:

const pw = require('playwright');
const start = require('./server');

(async () => {
  const servers = start();
  const browser = await pw.chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://127.0.0.1:52113/book.html');

  // does the svg holding the sprite take up space on screen?
  console.log('sprite box:', await page.locator('body > svg').evaluate(s => {
    const { width, height } = s.getBoundingClientRect();
    return `${width}x${height} display=${getComputedStyle(s).display}`;
  }));

  // Chromium's own accessibility tree: role, name and description
  const cdp = await page.context().newCDPSession(page);
  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  for (const n of nodes) {
    const role = n.role?.value;
    if (!['button', 'image', 'link'].includes(role) || n.ignored) continue;
    const desc = n.description?.value ? ` description="${n.description.value}"` : '';
    console.log(`${role} "${n.name?.value ?? ''}"${desc}`);
  }

  // tab order: does any svg receive focus?
  const order = [];
  for (let i = 0; i < 8; i++) {
    await page.keyboard.press('Tab');
    order.push(await page.evaluate(() => {
      const el = document.activeElement;
      return el.id ? `${el.tagName.toLowerCase()}#${el.id}` : el.tagName.toLowerCase();
    }));
  }
  console.log('tab order:', order.join(' > '));
  await browser.close();
  servers.forEach(s => s.close());
})();
sprite box: 0x0 display=none
button "Heart" description="Add to wishlist"
button "Add to wishlist"
image "In stock"
image "Heart"
link " +1 202 555 0143"
link "support@example.com"
link "Text us"
link "Download invoice (PDF)"
link "Invoice from files host"
link "Invoice from files host (attachment)"
tab order: button#wishlist-old > button#wishlist > summary > a > a > a > a > a

The phone icon is decorative: the number carries its meaning, and aria-hidden="true" took it out of the tree. The check mark next to the availability, on the other hand, carries information with no text beside it; with role="img" and aria-label it became image "In stock". The rule for an icon-only button is that the name belongs on the button, not the icon. aria-label is on the button, the icon is hidden, and the result is button "Add to wishlist".

The old button from the guess, however, comes out as button "Heart". The button's name is computed from its content, and the content is "Heart", the SVG's title. The title attribute becomes the name only when nothing else can; here it ended up as the description. A screen reader user hears a button called "Heart". On top of that, the title tooltip only appears on mouse hover; MDN's title page (1 October 2026) says it is problematic for touch, keyboard and magnifier users. Don't put important information only in title.

The last line is the tab order: two buttons, the summary and five links; no svg received focus. The focusable="false" you see in older code was for Internet Explorer and isn't needed today. Icon colour and size (fill, currentColor) are in the "Styling Images and SVG Icons" post of the CSS Properties: A Hands-On Guide series, and the alt of an SVG added with img is in "Images and Media".

In Production

The November 2026 status of this post's attributes, from web-features 3.46:

AttributeBaselineNote
data-*, title, draggablewidely (2015)Everywhere
spellcheckwidely (2017-11-28)A hint only
downloadwidely (2019-09-19)Same origin, blob:, data: only
translatewidely (2023-03-14)Completed with Firefox 111
contenteditable="plaintext-only"newly (2025-03-04)With Firefox 136
autocorrectnewly (2026-09-11)"HTML Forms 1"
hidden="until-found"limitedNot in Safari
autocapitalizelimitedNot in desktop Safari
pinglimitedNot in Firefox

Nu Html Checker passed every page in this post without errors; it won't catch a button named "Heart" or a note that stays outside the form, because both are valid as far as the spec is concerned. For those you need a test that reads the accessibility tree (like icons.js) or axe; how to put that in CI is in "Accessible Markup Basics".

Build these attributes into components: in Angular, let the component that renders the book title write translate="no" itself, and let a single icon button component that takes aria-label as a required input rule out unnamed buttons from the start.

Common Pitfalls

First, using contenteditable instead of an input: it isn't submitted and isn't validated.

Second, leaving an icon-only button's name to the SVG's title; the name belongs on the button.

Third, putting important information in title; it never shows on touch screens or with the keyboard.

Fourth, hiding a sprite with <svg hidden>; the box stays in place.

Fifth, relying on download for a file on another origin; set up the download with the header.

Sixth, making hidden="until-found" the only way to reach content; in Safari it stays closed.

A Small Experiment

★☆☆ On the old wishlist button, remove first the title attribute and then the title inside the SVG, and run icons.js after each step. How do the name and description change?

★★☆ Hide the answers in Bookstore's FAQ with hidden="until-found", add a class to the heading of the question opened in beforematch, and write a "Show all answers" button for Safari.

★★★ List the accessibility tree names of your project's icon-only buttons with a script like icons.js. Move every button whose name is empty or is the icon's name ("Heart") to a single icon button component.

The series ends with this extension post. Across thirteen posts we chose the right element for the right meaning and measured the attributes that complete it. There are two natural ways to continue: the upcoming Accessibility (a11y) series for the depth of ARIA, focus management and screen reader testing, and the upcoming CSS3: Flexbox, Grid and Modern CSS series for how these elements and icons look.