Learning objectives
- List the extension points HTML provides, and explain why invented attributes and unhyphenated element names are not among them
- Write valid
data-*attributes, read and write them throughdataset, and style them with attribute selectors - Decide between
class, adata-*attribute, a native attribute and an ARIA state for a given piece of state or a variant - Use custom element names to name structure with no script, and explain how they render, how they are styled and how
:definedtells the two states apart - Describe in outline what a custom element definition adds, and where the Components section teaches it
- Apply the "use the platform first" checklist before writing a component, and read what real pages actually do with these features
A fixed vocabulary and a growing page
Lesson 01 called HTML a language, and like any language it has a dictionary. The standard defines the elements and attributes, and browsers, screen readers, crawlers and validators all agree on what each one means. That shared dictionary is the whole value of the thing: a <nav> written in 2010 is still navigation to every reader in 2026.
The dictionary is also, by design, small. It has words for the shapes every document shares, and none for a trail card, a star rating or "this row is record 42." Every application needs names and state no general-purpose language could predict, so every application extends HTML, one way or another.
The slides list the ways people have done it, and the list is a tour of good and bad ideas: class names that act as private element names (<div class="superThing">), data-* attributes, <script> elements with made-up types used as inert text blocks, meta and link elements with invented names, values hidden in comments for a script to dig out, and custom elements. The HTML standard has its own list, in a section called "Extensibility," which opens: "HTML has a wide array of extensibility mechanisms that can be used for adding semantics in a safe manner." Among its list:
class, to extend an element "while using the most applicable existing 'real' HTML element, so that browsers and other tools that don't know of the extension can still support it somewhat well";data-*attributes, which "are guaranteed to never be touched by browsers";<meta name="" content="">for page-wide metadata, and registeredrelvalues for links;<script>with a customtype, to embed raw data;- microdata, the
itemscopeanditempropattributes, "to embed nested name-value pairs of data to be shared with other applications and sites"; - custom elements, "to extend the vocabulary of HTML."
Two corrections to the slides: a meta element's value goes in content, not value, and HTML has no <quote> element; it has q and blockquote.
What makes an extension "safe" is a promise from the language. Lesson 03 showed what the parser does with names it does not know: an invented attribute such as meme="true" is kept, scripts and selectors can find it, and it is invalid, because nothing stops a future version of HTML from giving meme a meaning of its own. The same goes for an invented element without a hyphen. The two extension points this lesson covers are the names HTML has promised never to take: any attribute that starts with data-, and any element name with a hyphen in it. The head extensions (meta, link rel, data blocks and microdata) are lesson 09's subject.
data-* attributes
The slides give the case for data-* in three lines: it "allows for user-defined meta data," it "reduces name collision problems," and it "removes overloading of class attributes or invention of non-standard attributes." Lesson 03 introduced it among the global attributes. This section covers how to use it well.
Naming
The standard defines a custom data attribute as "an attribute in no namespace whose name starts with the string 'data-', has at least one character after the hyphen, is a valid attribute local name, and contains no ASCII upper alphas." Any element may carry any number of them, with any value. In practice that means lowercase words joined by hyphens: data-trail-id, data-length-km, data-state.
The "no upper alphas" rule rarely bites in HTML, because the parser lowercases attribute names as it reads them. data-Difficulty="hard" arrives in the DOM as data-difficulty, which was confirmed in Chrome for this lesson. It matters in XML documents and in scripts, and it is a reason never to rely on capitals in a name. A library that adds data attributes to other people's pages should, in the standard's words, "include their name in the attribute names, to reduce the risk of clashes": data-trailnotes-map rather than data-map.
dataset and the camelCase mapping
Scripts can read a data attribute with getAttribute() like any other, but every element also has a dataset property: an object with one property per data attribute. Its names follow a fixed mapping. Drop the data- prefix, then turn every hyphen followed by a lowercase letter into that letter in uppercase. So data-trail-id is dataset.trailId and data-length-km is dataset.lengthKm. Writing runs the mapping backwards: setting dataset.lastSeen creates data-last-seen. Deleting a property removes the attribute.
Every value is a string
An attribute value is text, so everything that comes out of dataset is a string, and everything that goes in is turned into one. That causes two classic bugs. The first is sorting: compared as strings, "12.6" comes before "2.4", so a list sorted on dataset.lengthKm without Number() is wrong in a way that looks almost right. The second is booleans. li.dataset.open = false stores the string "false", and a non-empty string is truthy, so if (li.dataset.open) runs anyway. Both were checked in Chrome for this lesson. For an on-off state, use the attribute's presence, as HTML's own boolean attributes do: li.toggleAttribute('data-open', isOpen) to set it and 'open' in li.dataset to test it.
Styling with attribute selectors
CSS does not need dataset; it selects the attribute directly. [data-status] matches any element that has the attribute, [data-status="closed"] matches one value, and the other attribute operators work as they do anywhere else. The Selectors page covers them all.
This is the pattern the rest of the lesson leans on. The markup holds the state, CSS reads it, and a script only ever changes the attribute: one source of truth, visible in DevTools.
data-* versus class
The slides put the problem plainly: "Many devs overload the class attribute using it to signal the interactive nature of an element, hold element/component state, or even hold data. The data-* attributes may be better." A typical overloaded element looks like this:
The first version packs a name, a variant, a state, a script hook and an id into one list, each kind of token with its own naming convention, and nothing stops it from saying is-open is-closed at once. The second version gives each job its own place. The component's name is the element (a custom element, which the next section covers). The variant and the state are separate attributes, and an attribute holds exactly one value, so a card cannot be open and closed at the same time. The id is data a script can read as dataset.trailId.
This is the course's own convention, the one the site you are reading follows. Choose elements first, built-in and custom; put state and variants in data attributes; and use class rarely, for a group that differs from the default and has no better name. It is the cure for class-itis from lesson 05, carried one step further. A class does still have a proper job: the standard suggests class values "that describe the nature of the content," and a class that names a category (class="warning") is fine. What a class should not hold is changing state.
Native state comes first
Before inventing a data-state, check whether HTML or ARIA already has the state. A disclosure is <details open>, which the browser keeps up to date for you. A checked box is :checked, a disabled control is disabled, a hidden panel is hidden. A toggle button's state belongs in aria-pressed, the current page in a menu in aria-current="page", an expanded menu in aria-expanded. CSS can select every one of these ([aria-expanded="true"] works exactly like a data attribute), and they reach the accessibility tree. A data attribute does not: data-state="open" means nothing to a screen reader. If a state matters to the user, it needs a native or ARIA home, and a duplicate data-* copy of it is a second source of truth waiting to drift out of step.
Data attributes are for the rest: application state the platform has no word for, visual variants, and identifiers a script needs. The demo keeps all three in attributes.
What not to put in a data attribute
- Secrets. Everything in the HTML is public: one View Source away, cached, and readable by every extension and script on the page. API keys, tokens and other users' details do not belong in an attribute any more than in a comment.
- Anything the server must trust. A
data-priceordata-role="admin"can be edited in DevTools before a script reads it. Use it for display; check the real value on the server. Form Security makes the same argument about hidden fields. - Large or structured data. A kilobyte of escaped JSON in an attribute is hard to read and change. Use a data block,
<script type="application/json">, orfetch()it. - Anything another tool should read. The standard is explicit: "These attributes are not intended for use by software that is not known to the administrators of the site that uses the attributes." Its example is a music site that stores track lengths in
data-length; the site's own scripts may sort by them, but a search engine reading them would be misusing them. Metadata for other software belongs in a shared vocabulary such as schema.org, which lesson 09 covers. - What a standard attribute already says. Again the standard: "When a standard attribute is available, there is no need for a custom data attribute to be used." Not
data-langbutlang; notdata-disabledbutdisabled.
One more line from the standard is a good test for any design: "Authors should carefully design such extensions so that when the attributes are ignored and any associated CSS dropped, the page is still usable."
Custom elements as names
Data attributes extend what an element carries. Custom elements extend what an element is called. Lesson 03 showed the parser's side: <foo> becomes an HTMLUnknownElement, while <trail-card>, with its hyphen, becomes an ordinary HTMLElement waiting for a definition. That waiting element is already useful, before any script exists.
The naming rules
A valid custom element name, in the standard's terms:
- starts with a lowercase ASCII letter, so the parser reads it as a tag name;
- contains a hyphen;
- contains no uppercase ASCII letters, so the name can be matched without regard to case;
- is otherwise a valid element name, which allows a wide range of characters (the standard's own example is
<math-α>); - is not one of eight names already taken by SVG and MathML:
annotation-xml,color-profile,font-face,font-face-src,font-face-uri,font-face-format,font-face-nameandmissing-glyph.
The hyphen is the important rule, and it is a promise. In the standard's words, it is "used for namespacing and to ensure forward compatibility (since no elements will be added to HTML, SVG, or MathML with hyphen-containing local names going forward)." The slides describe dashed names as a convention; they are a requirement now. <trailcard> can be written and the parser will keep it, but it is invalid, it can never be given a definition (customElements.define('trailcard', …) throws a SyntaxError), and a future standard could claim the name.
Structure without script
An undefined custom element is a plain element with a name. It is in the DOM, querySelector('trail-card') finds it, and CSS selects it by its name with no class at all. It has no role in the accessibility tree, so assistive technology looks straight through it to the headings, lists and paragraphs inside; checked in Chrome's accessibility tree, a <trail-card> around an h2 and a p exposes just the heading and the paragraph. That is the right division of labor. The custom name tells authors and style sheets what the box is; the native elements inside tell users.
The course already uses this. The <form-field> pattern in Forms wraps a label, a control and its messages in an element whose name says what it holds, with no script and no div. The same idea scales to any component a site repeats: <trail-card>, <site-header>, <price-tag>.
Three details make it work. First, an unknown element renders inline, like a span, because the browser's default style sheet has no rule for it. Almost every custom element that holds blocks needs display set first. Second, :defined tells the two states apart. It matches every built-in element (including <foo>, as Chrome confirms) and every custom element whose definition has run; :not(:defined) matches a custom element still waiting. Third, the name is a hook for later: when a definition does arrive, every <trail-card> on the page is upgraded in place, without a line of the markup changing.
The slides make the strategic case for doing this even before you need behavior. <div class="wizard"> and <my-wizard> look the same today, but the custom element "actually allows us to more easily 'upscale' to a web component later on." With the class, upscaling means a framework component that emits the div, or a script that hunts for the class. With the element, it means one customElements.define() call, which the demo runs.
Clashes the hyphen cannot prevent
The hyphen keeps your names away from the language. It does not keep them away from each other. A page can hold only one definition per name; a second customElements.define() for a name already defined throws a NotSupportedError. If a site's own <date-picker> meets a library's, one of them loses. The defense is a short prefix used everywhere; the most common custom elements on the web, later in this lesson, are mostly prefixed.
Custom elements with behavior
A definition turns a name into a component. The Components section teaches this in nine steps; this is the outline, so that the parts have names when you get there.
- Registration and upgrade.
customElements.define()ties a name to a class. Elements already in the page are upgraded, and new ones get the class as they are parsed or created. Components 02 covers it, withwhenDefined()for scripts that need to wait. - Lifecycle callbacks. The browser calls
connectedCallbackwhen the element enters a document,disconnectedCallbackwhen it leaves,attributeChangedCallbackwhen an attribute listed inobservedAttributeschanges, andadoptedCallbackwhen it moves to another document.attributeChangedCallbackis how a component reacts when a script changes the data attributes from earlier in this lesson. Components 05. - Shadow DOM. An optional private subtree, with its own styles that the page's CSS does not reach. Useful for a widget that must look the same on any site; unnecessary for most of a site's own components, which are happy to be styled by the page. Components 04.
- Templates and slots.
<template>holds inert markup to clone;<slot>shows where the element's own children appear inside its shadow tree. Components 06, with declarative shadow DOM, which builds a shadow tree from HTML alone, in Components 09. - Form-associated custom elements. With
static formAssociated = trueandattachInternals(), a custom element can submit a value, take a label, validate and reset like a native control. Components 08. The sameElementInternalsobject can give the element a default role and expose custom states to CSS through:state(), Baseline since 2024.
Custom elements may also take attributes of their own. The standard allows "any namespace-less attribute that is relevant to the element's functioning, as determined by the element's author" on an autonomous custom element, so <trail-map zoom="12"> is valid in a way that <div zoom="12"> is not. On built-in elements, keep using data-*.
Autonomous and customized built-in elements
Everything so far is an autonomous custom element: a new name, a class extending HTMLElement. The standard also defines customized built-in elements, which extend an existing element and are written with the is attribute: <button is="plastic-button">, a real button with extra behavior. The idea is attractive, since the element keeps all of its native semantics. Chrome, Edge and Firefox support them. Safari does not and has said it will not: WebKit's published standards position on customized built-ins is "oppose," arguing that form-associated custom elements and ElementInternals cover the use cases better, and in September 2026 it closed a renewed request as a duplicate of that position. Write autonomous elements, and when you want a native element's behavior, put the native element inside: <copy-button><button>Copy</button></copy-button>.
HTML first, then the upgrade
The upgrade model is what makes custom elements fit the course's approach to building pages. The HTML arrives first and is already meaningful. The CSS arrives and styles it by element name. The script arrives last, if it arrives at all, and adds behavior to elements that are already on screen. The Foundations page calls this progressive enhancement: each layer adds to the one below it, and none of them is required for the one below it to work.
The question to ask of any component is what the page looks like at stage 2, because scripts fail more often than authors expect: a slow train connection, a blocked domain, an error elsewhere, a crawler that never runs JavaScript. A component built from native elements inside a named wrapper degrades into exactly those native elements. A component that ships an empty <trail-card></trail-card> and builds everything in connectedCallback degrades into nothing.
Two habits follow. Put the real content in the light DOM, as children the HTML already contains, and let the definition enhance it. And style the waiting state with :not(:defined) rather than hiding it: a snippet that hides undefined elements avoids a flash of unstyled content for the half-second before a script arrives, and leaves a blank page for everyone whose script never does.
Use the platform before writing code
The slides end their tour of custom elements with "more words of warning," and they are worth taking literally. "Just because you can should you?" "Custom elements are pretty cool but shared semantics are even cooler." Elements built by authors, "whether via framework or native DOM API, are never as fast and resilient as a natively implemented element." And: "When in doubt use the platform." Before reaching for a custom element with behavior, let alone a framework, walk down this list:
- Is there an element for it? Landmarks and sectioning (lesson 05), headings, text and lists (06), tables (07), and the interactive elements (08): disclosure, accordions, dialogs, popovers, meters.
- Is there an attribute for it?
hidden,inert,open,disabled,lang,popover, and the rest of the global attributes from lesson 03. - Is it a form control? Dates, colors, ranges, search, suggestions with
datalist, and built-in validation are in Form Fields. - Can CSS do it? Hover and focus states,
:has(),:checked, container queries and scroll snapping replace a great deal of old JavaScript. The CSS section covers them. - Only then a custom element, built from native parts, named, and working at stage 2.
- Only then a framework, for the problems that are genuinely application-sized.
The slides' last point explains why the order matters over time: "If something isn't available go ahead and simulate it with a framework but be aware it might eventually become native so let the old habits die." Lesson 08 is full of examples. The accordion library became <details name>, the modal library became <dialog>, the dropdown positioning library became popover and anchor positioning. Code written for the gap does not disappear when the gap closes; someone has to notice and delete it.
Divitis with dashes
Custom elements can repeat every mistake of div-itis with a more confident name. <site-nav> with no nav inside it is not a navigation landmark. <card-title> instead of an h3 is not a heading, and drops out of the outline a screen-reader user scans. <fancy-button> is the <div class="button"> problem again: no focus, no keyboard, announced as plain text. This is the first rule of ARIA, quoted in lesson 05 and lesson 08, applied to components: if a native element has the semantics and behavior you need, use it, and if a component needs them, put the native element inside. The custom name is for the wrapper. What users meet should still be HTML they know.
HTML by the data: correct versus common
This series has taught HTML as it is specified, the "Latin of the web." The slides close their introduction with a reminder that there is also HTML as it is written, the "English," and point to the HTTP Archive's Web Almanac, which measures what millions of real pages contain. Its most recent markup chapter, in the 2024 edition, is a useful reality check on this lesson's subjects. All figures below are for mobile pages.
divstill rules. 28.7% of all elements aredivs, ahead ofa(12.6%) andspan(11.2%). The chapter's verdict: "divitis" is "still a thing."classis everywhere. It is the most used attribute "by far": 48 billion occurrences, 33% of all attributes in the data set.- Data attributes are nearly universal. 90% of pages use at least one. The most common is
data-id, on 24% of pages; next comedata-load-timeanddata-tagging-id(20% each), both from analytics and tagging tools. - Custom elements are rising from a small base. 7.9% of pages used them in 2024, up from 5.4% in 2023 and 3.6% in 2022.
- Most of those custom elements come from products, not authors. The top ten are
wow-image(2.7% of pages, from Wix), a set ofrs-*elements from the Slider Revolution plugin, andpredictive-search(1.4%, a Shopify component). - They come with script. At the median, pages with custom elements loaded 1,286 kB of JavaScript, against 522 kB for pages without.
Read those numbers with care. The JavaScript figure is a correlation, not a cost of custom elements: the pages that use them mostly run on heavy platforms, and a name like <form-field> costs no script at all. Several of the most common data attributes belong to third-party tools, the use the standard warns against. And the div figure is the gap this series exists to close. The common version is the one you will inherit; the correct version is the one worth writing.
Ten lessons, one language
Lesson 01 opened with a phrase from the slides: HTML is "the Latin of the web." Learn it well, and everything built on top of it makes more sense. Ten lessons later, the claim can be checked against what the series covered.
- Principles. HTML as a language with a forgiving parser and a tree that actually runs (01); the document, its doctype, encoding and MIME type (02); elements, attributes and character references (03); content models, validity and the validator (04).
- Semantics. Landmarks and the accessibility tree (05); headings, text and lists (06); tables for data (07); the interactive elements that replaced a decade of widget libraries (08).
- Metadata. The head and the machine-readable layer (09); and this lesson, the extension points for everything the vocabulary leaves out.
One idea runs through all ten. The browser will accept almost anything you write, and render it, and the forgiving parser will never tell you that the result means nothing to a screen reader, a crawler or the next developer. The discipline is yours: choose the element for what the content is, keep state where every reader can see it, extend the language only at the points it has set aside, and check the tree rather than the pixels. Written that way, HTML is the most durable code you will ever ship. Pages from the early 1990s still render, and a page you write carefully this week can outlast every framework that will try to generate it.
Try it: extend a page the safe way
- Open data-attributes.html and, in the console, run
document.querySelector('trail-list').dataset.filter = 'hard'. The list filters with no event fired, because the CSS only reads the attribute. Now setdataset.open = falseon a trail and test it withif. What runs, and why? - In the same console, try
document.querySelector('li').dataset['length-km']and…dataset.lengthKm. Which name works for reading? Then try settingdataset['length-km'] = 1. What error comes back? - Open custom-names.html and, before pressing the button, run
customElements.define('trail-stats', class extends HTMLElement {}). Nothing changes on screen. Why? Then trycustomElements.define('trailstats', class extends HTMLElement {})andcustomElements.define('trail-card', class extends HTMLElement {})twice. Name each error. - Take a recent project of your own and list every
classvalue on one page. Sort them into four piles: component names, variants, states and script hooks. Which ones would become an element, which adata-*attribute, and which already have a native or ARIA home? - Pick one component from that project and write it as a custom element name with native elements inside, no script. Turn off CSS and JavaScript (in Firefox, View, Page Style, No Style; in DevTools, disable JavaScript). Is the content still in a sensible order, with real headings and lists?
- Run the validator from lesson 04 on a page that uses
<trailcard>,<trail-card>,meme="true"anddata-meme="true". Predict which two it reports before you look.
Summary
| Concept | Key points |
|---|---|
| Why extend | The vocabulary is small and shared; applications need more names and state. The standard lists safe extension points: class, data-*, meta, rel, data blocks, microdata, custom elements. Invented attributes and unhyphenated names are kept but invalid. |
data-* naming | Starts with data-, at least one more character, no uppercase. Lowercase words joined by hyphens; libraries add their name as a prefix. |
dataset | Drop data-; hyphen plus lowercase letter becomes the uppercase letter. data-length-km is dataset.lengthKm. Writing and delete change the attribute. |
| Strings | Every value is a string: convert with Number() before sorting; "false" is truthy, so use presence and toggleAttribute() for booleans. |
data-* versus class | Element for the name, data attributes for state and variants, class rarely and for categories. One attribute holds one value, so states cannot contradict each other. |
| Native state first | open, disabled, hidden, :checked, aria-expanded, aria-pressed, aria-current reach the accessibility tree; data attributes do not. |
| What not to store | Secrets, values the server must trust, large structured data, metadata for other tools (use schema.org), anything a standard attribute already says. |
| Custom element names | Lowercase start, a hyphen, no uppercase, not one of eight reserved SVG and MathML names. The hyphen keeps names away from future HTML, not from other libraries; use a prefix. |
| Names without script | An undefined custom element is an HTMLElement, renders inline (set display), has no role, and is styled by element selectors. :not(:defined) and :defined tell the states apart. |
| Behavior | customElements.define() upgrades elements in place. Lifecycle callbacks, shadow DOM, templates and slots, form association: the Components section. Use autonomous elements; WebKit opposes customized built-ins (is=). |
| Progressive enhancement | HTML, then CSS, then the definition. Content in the light DOM; every stage is a usable page; style the waiting state rather than hiding it. |
| Platform first | Element, attribute, form control, CSS, then a custom element, then a framework. Custom names wrap native elements; they never replace a heading, a button or a landmark. |
| By the data | Web Almanac 2024, mobile: div 28.7% of elements; class 33% of attributes; 90% of pages use data-*; 7.9% use custom elements, mostly from products such as Wix and Slider Revolution. |
Next steps
This is the end of the HTML series, and the start of everything that uses it. Several parts of the vocabulary are taught in sections of their own: Linking takes the a element from href to the request a click sends; Forms builds the form-field pattern from this lesson into complete, accessible forms; and Images covers img, picture and responsive images. CSS turns the attribute and element selectors used here into a whole styling system, and the DOM section is where the tree this series kept pointing at becomes something a script reads and changes. When a custom element needs behavior, Components picks up exactly where this lesson stops.
