An APIs series
Every framework you have used ships a component model. So does the browser. It has no build step, no runtime to download, no migration when the ecosystem moves on, and it has been in every shipping browser for years. This series is about that model โ what it actually gives you, where it is genuinely better than the framework you know, and where it is genuinely worse.
Step 01
This step uses custom elements as semantic HTML markers before any JavaScript exists. Pages written this way work with just HTML and CSS, following progressive enhancement principles โ combine that with hover effects, invokers (commandfor), dialogs, popovers, and view transitions, and you can get surprisingly far before writing a script. But starting here also means the move to registered web components, or to a static site generator, is seamless rather than a rewrite.
Custom elements can be used to add semantic meaning to your HTML structure even without JavaScript. This approach replaces "div soup" with meaningful, descriptive element names.
A responsive grid displaying product cards with semantic custom elements. Notice how the element names clearly describe what they represent.
<product-grid> - Container for product cards<product-card> - Individual product wrapper<card-image> - Product image container<card-content> - Content wrapper<product-title> - Product name<product-description> - Product details<price-display> - Price information<product-rating> - Star rating display<card-actions> - Action buttons containerDisplay component showing user information with profile stats. Demonstrates how custom elements can create a clear information hierarchy.
<user-profile> - Profile container<profile-avatar> - User image<profile-info> - User information wrapper<user-name> - Display name<user-role> - Job title/role<user-email> - Email address<profile-stats> - Statistics container<stat-item> - Individual statTo use custom elements well consider adopting modern CSS features including:
Layers control cascade specificity:
In Step 2: Custom elements, we'll add JavaScript to enhance these elements:
customElements.define()HTMLElementconnectedCallbackStep 02
Semantic markers become real behavior here. customElements.define() associates an element name with a class extending HTMLElement, and the browser starts calling its lifecycle callbacks. The line that trips people up most is the constructor versus connectedCallback split โ one runs before the element is anywhere near the document, the other runs once it is, and mixing them up is the most common source of "why is this null" bugs in a first custom element.
customElements.define() to make them web componentsThe Custom Elements API allows you to define new HTML elements with JavaScript logic. You register a custom element and associate it with a class:
Every custom element extends HTMLElement and must call super() first in the constructor:
Hello from MyElement!
`; } } // Register the element customElements.define('my-element', MyElement);HTMLElement: Or a specific HTML element if the idea was fully realized (not sadly due to Apple holding out)super() firstcustomElements.define()For setup that doesn't touch the Light DOM.
Use with Shadow DOM
For Light DOM manipulation and setup.
Mostly for Light DOM solutions
A basic custom element that reads an attribute of the element and displays a default or custom greeting.
A custom element with internal state and event handling. Demonstrates the full lifecycle of a component with user interaction.
slot AttributesThis component takes its content from its children instead of from attributes: each child carries a slot="โฆ" attribute saying which part of the card it belongs to, and the component reads those children and builds the card from them.
Elements can be "upgraded" (have their behavior added) at different times:
document.createElement(): Upgraded when appendedThis frame combines the delayed-registration demo with the registration status list below, so clicking the button shows both effects at once: the element upgrading in place, and its status flipping from โ Not Registered to โ Registered.
Upgrade is asynchronous, so "is this element ready?" is a real question with two different answers. customElements.get() asks it synchronously; customElements.whenDefined() returns a promise that resolves whenever the definition lands, even if that is later.
CSS asks the same question with :defined, which is how you avoid a flash of un-upgraded content while the defining script is still loading.
You can programmatically check which custom elements have been registered using the global customElements object โ an instance of the CustomElementRegistry interface โ in this case its get() method. Reload the page if you already registered it and see it showing as "Not Registered" and then register it to see it show the new state.
See it in action in the live demo at the end of the previous section โ that frame includes a <registration-status> element alongside the delayed-registration button, so registering delayed-element updates this list in real time.
Custom elements can be created programmatically using document.createElement(), just like native HTML elements. Remember, the DOM manipulates arbitrary elements and custom elements are included. In a sense the DOM is a complete framework API for HTML plus whatever you invent. That shouldn't surprise you because the frameworks ultimately use it to do their job. Such things may provide better developer ergonomics, but fundamentally they rely on all the DOM concepts you should understand.
Nothing about a custom element requires it to start out in the HTML โ createElement() plus appendChild() triggers the same connectedCallback that parsing the tag from markup does.
Extend HTMLElement directly
Use in HTML:
โ Fully supported across all browsers
Extend specific elements (e.g., HTMLButtonElement)
Use in HTML:
โ Limited browser support - avoid using
In Step 3: Component basics, we'll explore:
observedAttributes and attributeChangedCallbackElementInternals and CSS :state()Step 03
A custom element's public surface is a design decision, not just a technical one. This step is about that surface: the split between attributes (strings, visible in markup, easy to style from CSS) and properties (any JavaScript value, invisible in markup, set only from code), and reflection โ the discipline of keeping the two in sync so an element behaves the way a native one does. observedAttributes and attributeChangedCallback let a component react when its markup changes out from under it, and ElementInternals's custom states give you a third option: state CSS can match with :state() that nothing outside the component can set directly.
constructor() vs connectedCallback() usageUnderstanding the distinction between properties and attributes is fundamental to creating well-designed web components.
setAttribute()Components can react to attribute changes using the observedAttributes and attributeChangedCallback lifecycle method.
Use getters and setters to create a clean API and maintain synchronization between properties and attributes.
Boolean attributes follow a special pattern - they're present or absent, not true/false strings. Interestingly, JavaScript's weak typing kind of helps (or hurts) you here as non-empty strings are "true" and blank ones are false regardless of value. Conventionally, you may see folks using the value "true" or if following XHTML the name of the attribute disabled="disabled", but it is just the presence that matters so remove it if you are false don't set it to "false" as that actually will convert to true in JavaScript!
Reflecting to an attribute makes internal state visible to CSS, but it also makes it public: an attribute is part of your API, and anyone can set it. ElementInternals gives you the other option โ a private state set, matched from CSS with :state(), that nothing outside the component can write.
A fully functional toggle switch demonstrating properties, attributes, events, and state management.
checked - Boolean property/attribute for toggle statedisabled - Boolean property/attribute to disable interactionlabel - String property/attribute for accessibilitychange - Custom event fired when toggledFour toggles cover the states above โ default, pre-checked, disabled, and labeled โ an event log records every change event, and a small API panel drives the first toggle from JavaScript instead of a click, the same programmatic control the "Interactive API Demo" section below explains.
A progress bar with dynamic updates, demonstrating numeric property handling, validation, and custom events.
value - Numeric property for current progress valuemax - Maximum value (default: 100)color - String property for bar color customizationshow-percentage - Boolean to display percentage textpercentage - Computed getter for percentage valueprogress-change - Custom event fired when value changesFour bars show fixed values across the range and color options, plus an "Animate" button that ramps a fifth bar from 0 to 100 and back, one value setter call at a time.
A tab container demonstrating dynamic content switching, event delegation, and parent-child component communication. This is a common demo that shows a nice, but needless component?
Interact with component APIs programmatically using JavaScript. This demonstrates how to control components from external code. The DOM features will already be there, but you might want your own. We will revisit the toggle component even though we don't need to make one to show how you could do that.
See it in action in the toggle switch demo above โ that frame's own Toggle Checked, Toggle Disabled, and Get State buttons call exactly the setters and getters shown here, so clicking a switch and clicking a button drive the same checked/disabled property pair.
Well-designed components have clear, predictable APIs that are easy to use and understand. You'll likely want to lean into what is assumed understood by the developer and of course have documentation
In Step 4: Shadow DOM considerations, we'll explore:
::part, and ::slottedStep 04
A shadow root gives an element its own DOM subtree with its own style scope: the page's CSS cannot reach in, and the component's CSS cannot leak out. That isolation is the whole feature, and it is also why shadow DOM is wrong for most application-level markup โ SEO, form participation, and some accessibility tooling all expect one continuous document, not a boundary they cannot see past. This step covers what the boundary buys, what it costs, open versus closed roots, and the deliberate holes โ ::part, ::slotted, and custom properties โ that let a component author open specific gaps in it on purpose.
Shadow DOM provides encapsulation for web components by creating a separate DOM tree that's attached to an element but isolated from the main document tree. The main advantage of this is to protect the markup, style, and script (mostly events) from other parts of the page.
CSS defined inside shadow DOM doesn't affect the rest of the page, and external CSS doesn't affect the shadow DOM.
Internal DOM structure is hidden from external JavaScript, protecting implementation details.
Slots enable flexible content projection while maintaining encapsulation.
Some events are retargeted to maintain encapsulation boundaries.
When attaching a shadow root, you must specify whether it's open or closed.
Pros:
Cons:
Compare the same message banner component implemented with and without Shadow DOM to see the practical differences.
Both banners render the same content through the same type attribute. The page in the frame below also carries one global rule targeting .banner-content โ watch which banner it reaches.
Choosing between Shadow DOM and light DOM depends on your specific use case and requirements.
When using Shadow DOM, you need strategies to allow customization while maintaining encapsulation.
Encapsulation cuts both ways. The page cannot reach in, which is the point, and is also the problem the first time somebody wants your button in their brand color. The platform's answer is not to weaken the boundary but to let the component open specific holes in it.
In Step 5: Component lifecycle, we'll explore:
adoptedCallbackAbortController that removes every listener at onceStep 05
A custom element has five lifecycle callbacks, and most of the bugs people hit come from treating only two of them as real: constructor and connectedCallback. The other three โ disconnectedCallback, attributeChangedCallback, and the rarely-seen adoptedCallback โ are where resource management and cleanup live. This step walks through the full set, the timing between them, and the listener that outlives its element because nobody removed it.
Custom elements have five lifecycle callbacks that allow you to hook into different stages of an element's life. Understanding when and how each callback is triggered is crucial for building robust components.
adoptedCallback is the one nobody mentions, because it fires rarely: only when an element is moved into a different document, such as document.adoptNode() into an iframe. It matters when it happens, because document-scoped things the element captured earlier โ observers, stylesheets, references to document โ are now pointing at the wrong document.
The constructor has strict rules defined by the HTML specification. Following these rules prevents errors and ensures consistent behavior.
These callbacks are called when an element is added to or removed from the document. They're your opportunity to set up and tear down resources.
Called when element is added to the document:
Called when element is removed from the document:
The attributeChangedCallback is only called for attributes listed in observedAttributes. This improves performance by avoiding unnecessary callbacks.
Watch lifecycle callbacks fire in real-time as you interact with components. This demo logs every callback execution so you can see the exact order and timing.
A sketch of the idea โ every callback announces itself. The frame below runs the same pattern across three different elements (auto-clock, resize-tracker, network-status) logging straight to the console.
Buttons drive a real auto-clock element through create, add, move, attribute change, and remove. A resize tracker and a network status indicator can each be toggled on and off the same way. Every callback logs to the console below the frame.
Proper resource management is critical for preventing memory leaks and ensuring components behave correctly when added, removed, and re-added to the DOM.
A clock, a resize tracker, and a network status indicator all run in the frame above. The three samples here are teaching sketches of the same three patterns, not the demo's code: the real file formats its clock differently, puts the resize tracker in a shadow root, and โ for the network status โ uses the single AbortController shown below rather than the paired removeEventListener calls above. Open the frame's view-source button to compare.
Memory leaks occur when components hold references that prevent garbage collection. Always clean up resources in disconnectedCallback.
removeEventListenerdisconnect()clearTimeout or clearIntervalThe classic component leak is a listener registered on window or document in connectedCallback and never removed: the element goes away, the listener does not, and it holds a reference to the element forever. Removing them one by one means keeping a matching bound reference for each. An AbortController collapses all of that into one signal.
Defer expensive setup until the element is actually connected to the DOM:
Components can be removed and re-added. Handle this gracefully:
Avoid expensive re-renders when attributes change rapidly:
Explore how components respond to attribute changes throughout their lifecycle. Try changing different attributes to see how the component updates.
In Step 6: Slots and templates, we'll explore:
<template> as inert markup, and why you clone it rather than move itslotchangeStep 06
Steps 02 through 05 built an element that owns everything inside it. That is fine until somebody wants to put their markup in it โ a link in the header, an icon next to the title, another one of your components in the footer. Attributes cannot carry markup, so the answer is not another attribute. It is a hole in the component that the consumer fills. This step covers the two pieces that make that work: <template>, which lets you keep inert markup in the document and stamp copies of it, and <slot>, which decides where the consumer's own nodes render. Then the design question the two of them raise: when is something an attribute, and when is it a slot?
<template> to hold inert markup and stamp clones of itslotchange<template> is inert markupA <template> element is parsed like any other markup โ the parser reads it, validates it, and builds real nodes โ but it is never rendered. Its children do not go into the document tree at all. They go into a separate DocumentFragment hanging off the element's .content property, which lives outside the main tree.
That "outside the main tree" part is the whole feature, and it is more than a display trick. Scripts inside a template do not run. Images inside it do not load. Custom elements inside it are not upgraded. Nothing in there costs anything until you clone it into a tree that is actually being rendered. A <div hidden> full of markup does none of that: the browser still fetches its images and still runs its custom element definitions, it just refuses to paint the result.
To use it, clone the fragment. Always cloneNode(true) โ never append .content itself. Appending a DocumentFragment moves its children out of it and leaves the fragment empty, so the first instance of your component would render and every instance after it would get nothing. Cloning keeps the template reusable, which is the reason you put the markup in a template in the first place.
The template does not have to come from the document. The demo later in this step builds one with document.createElement('template') and a string of markup, which is the usual shape when a component ships as a single JavaScript module and cannot assume the page contains anything in particular. Either way, the parse happens once and every instance clones the result.
This is the sentence to hold on to: a <slot> changes where a node renders, not where it lives. When you write markup inside a custom element that has a shadow root, those nodes stay exactly where you put them โ in the light DOM, as children of the host element. The shadow tree declares a spot, and the browser paints them there. Nothing is moved, reparented, or copied.
Every consequence follows from that. card.children still lists what you wrote. card.querySelector('p') still finds the paragraph. Page-level CSS still styles it, because it is still an ordinary element in the ordinary document โ which is exactly why step 04's ::slotted() exists at all. If slotted nodes really moved into the shadow tree, the page would have lost the ability to style them and there would be nothing for ::slotted() to do.
A shadow tree may contain one unnamed <slot>, and that one is the default: any child of the host without a slot attribute is assigned to it. Give a slot a name and a child opts in by matching it with slot="name". Children whose slot value matches no slot in the tree are assigned nowhere and simply do not render.
The <span> goes to slot="title"; the <p> has no slot attribute, so it lands in the default slot. Note that the order of the children in the markup does not have to match the order of the slots in the shadow tree โ assignment is by name, and the shadow tree decides layout.
Anything you write between a slot's tags is fallback content. It renders only while nothing is assigned to that slot, and disappears the moment something is. In the template above, Untitled shows until the consumer supplies a title. Fallback is the cheapest way to make a component usable with no configuration at all, and it costs one line.
slotchangeSlot assignment is live. A consumer can append, remove, or re-slot a child at any time, and the shadow tree has to keep up. The slotchange event fires on a slot when its set of assigned nodes changes, including the very first assignment when the element is first populated โ so a listener attached in connectedCallback will hear about the initial content, not just later edits.
Two accessors read what a slot currently has. assignedElements() returns just the elements, which is what you want most of the time. assignedNodes() returns nodes, including text nodes and whitespace. Both take the same { flatten: true } option, and it does two things: it resolves nested slots โ if the assigned content is itself a slot in another component's tree, flatten follows it to the real nodes โ and, when nothing is assigned at all, it returns the slot's fallback content instead of an empty array. That second behavior is the one that surprises people, so keep it out of any check that is really asking "is this slot filled?".
That toggled attribute is the useful trick. The component cannot style an empty footer out of existence with CSS alone, but it can reflect "somebody gave me actions" onto the host as an attribute, and then :host(:not([has-actions])) footer { display: none; } inside the shadow tree does the rest. A slot's occupancy becomes a styling hook.
The card below has a named title slot, a default slot for its body, and a named actions slot whose footer is hidden until something is assigned to it. Each button changes the light DOM โ appending a button with slot="actions", removing them again, appending a plain paragraph โ and every one of those edits produces a slotchange entry in the log. Watch which slot fires: adding a paragraph does not disturb the actions slot, and adding an action does not disturb the default one.
Every component API is a series of decisions about which of these two an input is. Get it wrong in the configuration direction and consumers fight you with attribute values that want to be markup. Get it wrong in the composition direction and a component that should have taken a string demands three lines of boilerplate.
An attribute carries a scalar โ a string, or a string parsed into a number or boolean. It is part of the element's declared API surface: you can observe it, reflect it, validate it, and match it in CSS with :host([variant="danger"]). The component decides what the value means.
A slot carries arbitrary markup that the consumer owns โ text, elements, links, icons, even other components. The component never inspects it or decides what it means; it only decides where it goes. Nothing about it is reflectable, because it is not a value.
The two are not exclusive, and the best components use both on the same concept: a named slot for the rich case, with fallback content that renders an attribute for the simple one. Consumers who want a plain string set the attribute and never think about slots; consumers who want markup fill the slot and the fallback vanishes on its own.
In Step 7: Styling and theming, we'll explore:
adoptedStyleSheets and sharing one stylesheet across every instance::part is a versioning hazardStep 07
Step 04 showed the three ways style crosses the shadow boundary: ::part, ::slotted, and custom properties. This step is not about those mechanisms again. It is about the design problem they leave you with. A consumer cannot reach into your shadow root, so the only styling they will ever be able to do is the styling you decided in advance to allow โ which makes your set of custom properties a public API with all the obligations that word carries. This step is about choosing that set deliberately: naming it, defaulting it, shipping variants alongside it, letting one switch at the root theme every component on the page, and serving all of it from a single stylesheet no matter how many instances exist.
:host()adoptedStyleSheets::part is a versioning hazardEncapsulation makes the theming question unusually stark. With an ordinary <div class="card">, a consumer who wants a different border can write a selector and get one, whether you anticipated it or not. With a shadow root they cannot. There is no selector they can write, no specificity they can escalate to, no !important that helps. Whatever you did not expose, they cannot change.
The other half is the part people forget. Whatever you did expose, you have promised to keep. The moment a consumer writes --card-bg: #101828 in their stylesheet, that property name is load-bearing in somebody else's code. Renaming it, or quietly ceasing to read it, breaks their page in a way no compiler will catch โ the page just renders wrong. A theming surface is not a convenience you sprinkle on at the end; it is versioned API, and it deserves the same care as a method signature.
Two rules follow from that, and both are visible in those three lines. Name properties after the component. Custom properties inherit, and they live in one flat namespace shared by the entire document. A component that reads --bg will happily pick up a --bg some unrelated widget set on <body> for its own purposes, and the two of them will fight over a name neither one owns. --card-bg cannot collide with --dialog-bg. Prefix everything.
Always supply a fallback. The second argument to var() is what renders when the page sets nothing, and a component whose defaults are all var(--card-bg) with no fallback renders as unstyled wreckage on any page that has not opted in. The fallback is what makes the component usable before anybody reads your documentation โ and it doubles as documentation, since a reader can see the intended value right next to the property name.
Everything above rests on one asymmetry in how the boundary works, and it is worth stating plainly because it looks like an inconsistency until you see why it is not.
Selector matching stops at the boundary. A page-level rule names elements the page can see, and the nodes inside a shadow root are not among them, so the rule simply never matches. Inheritance does not stop at the boundary. Inherited values โ color, font-family, and every custom property โ flow down the flattened tree, and the flattened tree runs straight through shadow roots. A custom property set on :root is therefore in scope on every node of every shadow tree on the page, and var() inside the component reads it exactly as it would read any other inherited value.
So the component author is not fighting the boundary when they design a property surface โ they are using the one channel that was always open. Step 04 walks the full set of crossing points, ::part and ::slotted included; see step 04's styling strategies for the mechanics. What matters here is that of the three, this is the one that scales: it needs no coordination about node names, it works on any number of components at once, and it is the same mechanism a page already uses to theme itself.
:host()Not every styling decision belongs to the consumer. Some belong to the component: a card has a warning presentation, a button has a danger presentation, and the component author is the one who knows what those should look like. That is what :host() is for. It matches the host element from inside the shadow tree, conditioned on a selector the host matches, so the component can ship a named variant and the consumer opts in with one attribute.
Notice what the first rule sets: not background, but the property the base rule already reads. A variant that overrides the custom properties rather than the declarations stays composable โ the base :host rule remains the single place where background is decided, and a consumer who sets --card-bg on the warning card from the page still wins, because for normal declarations the outer tree's rules take precedence over the shadow tree's. Set background outright in the variant and you have quietly made that card untuneable.
Where variants come from matters more than it looks. An attribute the component defines and matches with :host([variant]) is a declared part of its API, the same way step 03 treated any other attribute: enumerable, documentable, and matched identically in every browser.
There is a tempting alternative that you should not take. :host-context() matches on an ancestor of the host, which sounds like the natural way to pick a theme up from a wrapper โ but it is Chromium-only, with no cross-browser equivalent, and step 04's compatibility warning covers what to do instead.
Dark mode is where the design pays off, and it is the argument for property-based theming in one screenshot. Because custom properties inherit, the page does not have to visit each component, or know which components exist, or care whether they use shadow DOM at all. It redeclares the properties once at the root and every consumer of them updates โ including components that were written years earlier by somebody who never considered dark mode.
Both forms are in there because a real site needs both. The media query reads the operating system's preference, which is the right default and requires no interaction. But users override their system preference all the time โ a dark desktop and one site they want light, a bright room, a document they are about to print โ and a media query alone gives them no way to say so. The explicit toggle is what turns a system preference into a user choice.
The two have to be ordered so the toggle wins. The [data-mode] rules come last and carry an attribute selector, so they outrank the plain :root inside the media query on both count and order; the media query changes nothing about specificity by itself. Setting data-mode on <html> is then one line of JavaScript, and it is worth persisting the choice so the next page load does not undo it.
The demo below deliberately does not do this. It carries the explicit toggle and no media query at all, so the buttons stay the single variable while you read the theming surface โ an operating system set to dark changes nothing in that frame. That is a simplification made for a teaching demo, and it is exactly the simplification not to carry into real work.
adoptedStyleSheetsEverything so far has been about which values cross the boundary. This is about the rules themselves. Style scoping is per-root, so every shadow root needs the component's CSS inside it โ and if you supply that CSS as a <style> element in a cloned template, each instance gets its own stylesheet object with its own rule objects. A hundred cards means a hundred of them. (Engines do cache the parsed result of identical style text, so the re-parsing is usually not where you lose; the duplicated sheets are.)
A constructed CSSStyleSheet gives you one instead. You build it once at module scope, fill it with replaceSync(), and hand it to as many shadow roots as you like. One sheet object, shared โ not copied โ by every root that adopts it.
"Shared" is meant literally, and it is the second reason to reach for this. Mutating the sheet later โ through replaceSync() or the CSSOM โ updates every shadow root that adopted it, in one operation, with no traversal of the document. The property holds a list rather than a single sheet, so a root can adopt several at once: a shared design-system sheet plus a small component-specific one is a common and sensible shape. It is an ObservableArray rather than a plain one, so assign a new array to it rather than pushing into the old.
Two themed-card elements, one component definition, one shared stylesheet. Every color in both cards comes from a custom property, and the three theme buttons do nothing but set data-theme on <html> โ no code in the page touches a shadow root. Watch the second card as you cycle themes: its :host([variant="warning"]) rule declares --card-bg on the host itself, which outranks the value inherited from :root, so its amber holds under every theme while the first card follows the page.
Notice that the variant declares --card-fg too, and that this is not decoration. A variant that overrides some of the properties a component reads and lets the rest keep inheriting has left an incomplete surface, and the gap stays invisible until somebody applies a theme nobody tested against it. Fix a background and leave the foreground inheriting, and the first dark theme to come along hands that card near-white text to paint on its own light background โ a card that renders as an empty box. Colors that depend on each other have to be overridden together. The page also carries a loud header { color: #dc2626 } rule that never matches anything, because each card's header is on the far side of the boundary.
::part is a narrower promiseStep 04 covers what ::part does and how to write it. The design question is when to reach for it, and the answer turns on what each of the two mechanisms actually names.
--card-bg is a slot in your component's configuration. What reads it, how many rules use it, and what markup sits underneath are entirely your business. You can rewrite the shadow tree from scratch and the property keeps working, as long as something still paints with it.
part="header" exposes a specific element in a specific structure. Consumers write rules against it that assume it is that kind of element, in that position, with those box-model properties. Rename it, or restructure around it, and every one of those rules silently stops matching.
So the two are not interchangeable tools with different syntax; they are promises of very different width. A property commits you to reading a name. A part commits you to keeping a piece of your internal structure โ which is exactly the thing encapsulation was supposed to let you change freely.
Prefer properties for values, which is most of what consumers want. Reserve ::part for hooks that are genuinely structural and that no property can express: a consumer who needs to reposition your close button, or give one internal element a layout of their own, has no property-shaped way to ask. When you do expose a part, name it for its role rather than its markup โ part="dismiss" survives the day the <button> becomes an <a>; part="close-button" does not.
In Step 8: Form-associated elements, we'll explore:
static formAssociated and what ElementInternals hands youStep 08
Every component this series has built so far is self-contained: it renders, it reacts, and nothing outside it needs to know what is inside. A form control is the opposite, because the browser collects it: it has a name and a value the form gathers on submit, a validity state that can stop that submit, and a set of behaviors โ reset, disable, restore โ the form drives rather than your code. A custom element gets none of that by default, and putting an <input> inside it does not help, because a control in your shadow tree has no form owner at all. This step covers the opt-in that fixes it: static formAssociated and the form half of ElementInternals, which together let the browser treat your element as one of its own controls โ and the parts of being a control that no API hands you.
ElementInternals gives you โ and what it does notStart with what actually happens, because it is less dramatic than a bug and easier to miss. Put <my-field name="email"> inside a <form>, give it a beautiful editing experience, and mark it required. Nothing errors. The form does not complain. It also does not collect anything: the element is not on the form's list of controls, so FormData has no entry for it, the required attribute is an attribute nobody reads, and pressing Submit with the field untouched submits the form happily, with the field simply absent.
Nesting a real control inside does not rescue it either. A form control's owner is found in its own tree, so an <input> in your shadow root has no form owner โ not the outer form, not any form โ and is never submitted with it. That is not an oversight; it is encapsulation doing exactly what step 04 said it does. The boundary that keeps the page out of your component keeps your component out of the page's form.
Before form association existed, the workaround was a hidden <input> in the light DOM shadowing the real UI, written to by hand on every change. It submits, which is the only thing it does well. Reset clears the hidden input and leaves your visible UI showing the old value. A disabled ancestor <fieldset> greys out nothing, because there is nothing of yours for it to reach. Validation either points at an invisible element or is reimplemented from scratch, with your own bubble, your own submit interception, and your own bug the day somebody submits by pressing Enter. Every one of those is a symptom of the same thing: the browser does not know your element exists. The rest of this step is about telling it.
Two things, and both are required. A static field on the class declares that instances of this element are form controls, and attachInternals() returns the object through which the element talks to the form.
formAssociated is read once, when customElements.define() runs, and it is read off the constructor โ so it has to be a static field or a static getter, not something you assign to an instance later. Setting it changes the element itself, before you write another line: it becomes labelable, so <label for> associates with it; it supports disabled; it appears in form.elements; and it inherits the disabled state of an ancestor <fieldset>.
attachInternals() is the same call step 03 used for custom states, and the same object โ there is only ever one per element. What formAssociated unlocks is its form half: form, setFormValue(), setValidity(), validity, labels, and the rest.
One call does it. setFormValue() tells the form what this control's value currently is, and from that moment the element has an entry in FormData and in the submitted body like any native control.
Two things about that line matter more than its length. The first is that the browser never asks: it stores whatever you last handed it, so #sync() has to run on every change that alters the value โ input events, programmatic property sets, anything. Miss one and the form submits a stale value with no warning, because from the form's point of view nothing is wrong. The second is that the element still needs a name attribute, exactly like a native control. No name, no entry; the value is set and simply never submitted.
The argument does not have to be a string. Pass a File for an upload control, or null to contribute nothing at all โ the way an unchecked checkbox contributes nothing. Pass a FormData when one control owns several entries: a date-range picker that submits start and end, or a card field that submits a number and an expiry. Those entries carry their own names, taken from the FormData rather than from the element's name. There is also an optional second argument, a state value, which the browser keeps separately and hands back to formStateRestoreCallback() โ useful when what you need to restore your UI is not the same as what you submit.
This is the part that is genuinely hard to fake, and the reason form association is worth the ceremony. setValidity() does not record a note for your own code to check later; it puts the element into the browser's constraint validation machinery, next to every <input required> on the page.
Note what it does not do, because this is where the required attribute from the opening section finally gets its answer. Form association never teaches the browser what your attributes mean. required is still an attribute nobody reads on your behalf โ the sample below reads it with hasAttribute() and decides for itself โ and the same goes for pattern, minlength, and every other constraint name you choose to honor. What you get from the platform is the machinery that acts on the verdict, not the rule that reaches it.
The three arguments each do one job. The first is a set of validity flags โ the same names a native control reports: valueMissing, typeMismatch, patternMismatch, rangeOverflow, customError, and the rest. Any flag you leave out is false, so setValidity({}) is how you say "valid" and is the branch people forget: a control that only ever sets flags never becomes valid again. The second is the message the browser will display, and it is required whenever any flag is true โ passing flags without a message is a TypeError, not a silently empty bubble. The third is an anchor: an element inside your shadow tree that the browser scrolls to and points the bubble at. Omit it and submission is still blocked, but the browser has no node inside your component to aim at.
What you get in return is everything constraint validation already does, without writing any of it. Submission stops before the submit event fires, so there is nothing to intercept and no handler to remember to guard. :invalid and :valid match your host element in CSS. form.checkValidity() counts your element in, and form.reportValidity() shows your message.
One piece of that surface is not free, and it catches people who assume the element is now a control in every respect. The validity state is real, but the validity properties are not there: element.validity, element.validationMessage, element.willValidate, and element.form are all undefined on a form-associated custom element unless you put them there. They live on ElementInternals, and ElementInternals is deliberately private โ the point of putting the form API on a separate object is that the page cannot reach it. Forwarding the ones you want is a handful of one-line getters, and worth writing, because that is what makes your element inspectable by code that expects a native control.
One consequence worth knowing before it surprises you: a disabled control is barred from constraint validation entirely. An element that is disabled, or sits inside a disabled <fieldset>, has internals.willValidate read false, submits no value, and cannot block the form no matter what flags are set on it. That is native behavior, and now it is yours too.
Being a control is not only about submission. Forms do things to their controls, and a form-associated custom element gets told about each of them through four callbacks that sit alongside the lifecycle set from step 05.
null)<fieldset> was disabled or re-enabled"restore") or autofill ("autocomplete")formResetCallback() is a notification, not a reset. The browser restores its own controls to their default values and then tells you it happened; if your callback does nothing, the form clears every native field around you and your element sits there still showing the old value. Note the second line, too โ clearing the visible input does not clear the value the form holds, so the callback has to run the same #sync() as any other change. That is the argument for putting value and validity in one function: there is only one place that can get out of step, and everything calls it.
formDisabledCallback() is the one that would be genuinely painful to do yourself. Disabled state is inherited from any ancestor <fieldset>, at any depth, and it changes whenever that fieldset does โ there is no attribute on your element to observe and no event to listen for. The browser walks it for you and calls the method.
One <email-field> in an ordinary <form>, with an ordinary submit button. Press Submit with the field empty and watch what happens: the browser's own validation bubble appears, and the message in it is the string passed to setValidity(). The submit handler below never runs โ the output block still reads "(nothing submitted yet)". Nothing in the page checked anything.
Then type a valid address and submit: the output shows an "email" entry holding what you typed, read straight out of new FormData(form), which is the proof that the element is a real entry rather than something the page copied across by hand. Reset clears it, through formResetCallback(). The red border while the field is invalid comes from :host(:state(invalid)) โ step 03's custom states, not a class the page can set.
The last button is the one to watch closely. It does not call formDisabledCallback(); it toggles disabled on the ancestor <fieldset> and nothing else. The inner input greys out anyway, because the browser propagated the state and invoked the callback. Submit while it is disabled and the form goes through with no email entry at all โ the barred-from-validation rule from the previous section, visible.
Form association makes the element a control to the form. It does not make it a control to the person using it, and the gap is easy to ship without noticing, because the demo above submits correctly while still being worse than an <input> in several ways.
Focus does not route itself. The host is labelable, so <label for> associates and internals.labels lists the labels โ but clicking that label calls focus() on your host, and a host that is not focusable passes nothing inward, so the focus lands nowhere. The click itself is not lost โ label activation still dispatches a click event at the host, so a listener there fires exactly as you would expect. It is the caret that never arrives, which is the half users notice and the half tests do not. Tabbing still reaches the control, because sequential focus navigation walks into open shadow roots on its own; it is every deliberate focus that lands nowhere, including your own element.focus(). Pass delegatesFocus: true when you attach the shadow root, or manage tabindex and override focus() yourself.
The accessible name is not automatic either. internals.ariaLabel, internals.role and their siblings set the element's defaults in the accessibility tree โ deliberately, so that an author who writes aria-label on your element in their markup still wins. That is the right precedence, and it also means your defaults are only defaults: set none and there is no name to fall back to. The demo above has the bug on purpose. Its <label for> associates with the host, but the node a screen reader actually lands on is the <input> inside the shadow root, and nothing ever named that input โ so it is announced by its placeholder. Association is not naming.
Keyboard behavior is entirely yours. Native controls carry conventions the browser implements: arrow keys move between radio buttons, Escape closes a picker, Enter in a text field submits the form. A form-associated custom element inherits none of them โ the inner input has no form owner, so Enter does not submit โ and every one you want, you write and test yourself.
In Step 9: Declarative shadow DOM, we'll explore:
<template shadowrootmode>: a shadow root that arrives from the parser, with no JavaScript at allinnerHTML will not parse a declarative shadow root, and what setHTMLUnsafe() is warning you aboutgetHTML()Step 09
Eight steps of components, and all of them share a dependency nobody has said out loud: none of it exists until JavaScript runs. The parser meets <user-card>, finds no definition for the name, and renders an empty inline element; everything that makes it a card is created afterwards, by a script, inside a callback. Declarative shadow DOM removes that dependency for the markup half. A <template> with one attribute is consumed by the parser and becomes the host's shadow root โ styled, slotted, and painted before a byte of your JavaScript has been fetched. This step covers that attribute, the one-line guard that makes the later upgrade an adoption rather than a rebuild, the API that refuses to parse a declarative shadow root and the one that will, and what all of it means for a page built the way this one is.
Follow what the browser actually does with <user-card> on a first load. The parser reaches the tag, sees a name it does not recognize, and creates an element for it โ an HTMLElement with no behavior, no shadow root, and, unless the page styled it, display: inline. It has children, because you wrote them, but nothing arranges them. Then the page paints. What the user sees at that moment is whatever your light DOM happened to be: a stack of unstyled spans, or, in the common case where the component supplies all of its own markup, nothing at all.
Some time later the module arrives, define() runs, every matching element upgrades, and connectedCallback builds the shadow tree. The card appears. The gap between those two moments is the flash: a blank or misshapen region that reflows into a component once the network and the main thread have both cooperated. On a fast connection it is a flicker. On a slow one it is the page. And if the script fails to parse, or 404s, or is blocked by an extension, the gap is permanent โ there is no state in which the component renders and no error the user can act on.
That is a strange place for this series to have ended up, because step 01 started from the opposite premise. Markup first, behavior after: a <product-card> that meant something before any script existed, and worked with HTML and CSS alone. Every step since has added capability, and each one quietly moved more of the component's substance out of the document and into a callback. Declarative shadow DOM is how you get the first half back without giving up the second.
<template shadowrootmode>The whole feature is one attribute. A <template> that carries shadowrootmode and sits as a direct child of some element is not treated as a template at all: the HTML parser consumes it, attaches a shadow root to that parent, moves the template's contents into the root, and removes the template from the tree. By the time the document is parsed there is no <template> left to find โ there is a host with a shadow root, exactly as if attachShadow() had been called and filled.
Read that markup with the previous eight steps in mind and notice how much of the model is already present with no JavaScript anywhere. The <style> is scoped to the root, so :host works and the rules cannot leak. The <slot> projects the light-DOM <span> into place, fallback content and all, by exactly the mechanism step 06 described. What is missing is only the behavior: there is no class, no registered name, nothing listening. The boundary and the markup arrived without them.
Four attributes in all: the one that triggers the behavior, and three that set the options attachShadow() would otherwise take. They have to be spelled on the template because there is no call to pass them to.
"open" or "closed"; required, and the reason the parser treats the template specially at alldelegatesFocus: true that step 08 needed to route a label click inwardcloneNode(true)A closed declarative root is worth a moment's thought, because the markup is right there in the document for anyone to read โ which is the clearest demonstration yet of step 04's point that mode: "closed" is an encapsulation choice and not a security one. It also breaks the guard in the next section, and not gently: this.shadowRoot is null for a closed root, so the check concludes there is nothing there and calls attachShadow({ mode: 'open' }) โ against a root whose mode is closed. That mismatch throws NotSupportedError: The requested mode does not match the existing declarative shadow root's mode, uncaught, taking the rest of connectedCallback down with it. The element ends up with the parser's markup and none of its behavior. The element can still find its own root through attachInternals().shadowRoot โ the same ElementInternals object step 08 used, which reports the root whatever its mode โ but that is one more thing to remember for a mode you probably did not need.
Eventually the definition loads and the element upgrades. The constructor runs, then connectedCallback โ and it runs against an element that already has a shadow root, already populated, already painted. Every component in this series so far has begun that callback by creating one.
Do that here and you have written the bug this whole step exists to avoid โ and nothing will stop you. attachShadow() normally throws a NotSupportedError on an element that already has a root, but a root the parser delivered is a deliberate exception: the call succeeds, empties that root, and hands it back. The exception exists so that components written before any of this shipped do not break on a server-rendered page. What it costs you is the warning. The markup the server sent is discarded, rebuilt from a string, and the flash you paid bytes to avoid comes back โ with nothing in the console to say so. The guard is one line.
Two conditions on that exception are worth knowing before you rely on it. The mode has to match: asking for { mode: 'open' } against a template that declared shadowrootmode="closed" throws NotSupportedError rather than adopting, which is the closed-root trap from the previous section. And it is one-shot โ the first call converts the parser's root into an ordinary shadow root, so a second attachShadow() on the same element throws NotSupportedError: Shadow root cannot be created on a host which already hosts a shadow tree, exactly as it would anywhere else. The exception is a migration ramp for old code, not a mode you can build on.
That shape is worth reading as a claim about responsibility rather than as a null check. The markup is no longer the callback's job in the common case; the callback's job is behavior. It queries nodes that are already there, attaches listeners to them, and sets whatever state the static markup could not express. Everything above the guard is a fallback for the one case the server did not cover.
Which means the callback has to be written so that both entries land in the same place. A component whose declarative markup and whose runtime template have drifted apart renders one way on first load and another way when created with createElement(), and the difference shows up in whichever of the two paths you test less.
innerHTML will not parse itHere is the one that costs an afternoon. You fetch a fragment of server-rendered HTML, complete with <template shadowrootmode>, assign it to innerHTML, and get no shadow root. No exception, no warning, no console message. The template is simply still there โ an ordinary, inert <template> element with your component's markup sitting unrendered inside its .content, precisely as step 06 described templates behaving. The card does not appear, and the markup you are staring at in DevTools looks correct.
Read that last pair carefully, because it is the same silent nothing arriving by a second route. The parser attaches each root to the template's parent within the fragment it is building, and the element you called the method on is not part of that fragment โ it is the context, not a node in the result. So setHTMLUnsafe() cannot give the context element a shadow root, however the string is written: a <template shadowrootmode> at the top level of the string has nothing to attach to and survives as an ordinary inert template. Wrap it in the host element it belongs to and the identical string works. If you need a root on the element itself, that is what attachShadow() is for.
This is deliberate, and the reason is worth understanding because it explains the second method's name. There is an enormous amount of existing code that assigns strings to innerHTML, much of it after passing the string through a sanitizer that walks the resulting tree. If innerHTML had been taught to create shadow roots, every one of those call sites would have gained the ability to attach content that a sanitizer written before this feature existed does not look inside โ markup that renders to the user while being invisible to the code that was supposed to inspect it. Silently changing what an old API does to old input was not an option, so the capability went into a new method, and that method was named after the promise you are making by calling it.
Element.setHTMLUnsafe() is the in-place form; Document.parseHTMLUnsafe() is the standalone one, returning a whole document. Both parse declarative shadow roots. Neither sanitizes anything. The rule is the ordinary one for injecting markup, only with sharper consequences: it is safe for HTML you generated, and it is never safe for HTML a user wrote.
Which is not the end of the story, because each has a sanitizing sibling and they are the ones to reach for when the HTML is not yours. Element.setHTML() and Document.parseHTML() parse declarative shadow roots and run the Sanitizer over the result, including inside the roots they just created: a string whose template holds <p onclick="x()">hi</p><script>โฆ</script> comes back as a shadow root containing <p>hi</p>, handler and script both gone, where setHTMLUnsafe() would have kept them. No options are needed for that: a declarative template is consumed by the parser into a shadow root before the sanitizer walks the tree, so the allowed-element list never gets a say over it.
That list does have a say over everything else, and it is the real limit on using the safe pair with components. The built-in configuration allows a fixed set of known HTML elements, and your custom element is not among them โ so the host is removed and its shadow root goes with it.
So the sanitizing pair is the right reach for untrusted markup, and it handles declarative shadow roots correctly โ but shipping components through it means widening the configuration to admit your own element names, deliberately and by name. That is a decision to make with your eyes open, not a default to lean on.
The markup in this step has to come from somewhere. Writing it by hand is fine for one card and hopeless for a page of them, so the platform provides the other direction: a way to read a live element back out including its shadow roots, in the declarative form the parser understands.
getHTML() with no arguments is the innerHTML getter with a longer name: it walks the light DOM and skips shadow roots entirely, which is the symmetric counterpart of the setter refusing to create them. Pass serializableShadowRoots: true and every shadow root marked serializable is emitted as a <template shadowrootmode> in the output, nested exactly where the parser will want to find it. There is also a shadowRoots option taking an explicit array of roots to include, which is how a closed root โ whose reference only its own component holds โ can be serialized by that component.
Serializability is opt-in for the same family of reasons the parsing is. A root is included only if it was created with attachShadow({ mode: 'open', serializable: true }), or arrived from a template carrying shadowrootserializable. Forget the flag and getHTML() returns markup with the shadow roots quietly missing โ which will look, on the round trip, exactly like the innerHTML bug above, from the other end.
What this closes is the loop. A rendering environment โ a server, a build step, a headless browser โ can define the components, instantiate them, let them build their shadow roots the ordinary way, and then serialize the whole result to HTML that any browser will render without running any of it again.
Two <user-card> elements and one class. The first card is in the page's source with its shadow root written out as a template, so the parser builds it โ border, type scale, grey role line and all โ before the module at the bottom of the file has been requested. The status line inside it reads "Not yet upgraded" because that string is in the markup; by the time you see the frame the definition has loaded and overwritten it, which is what the upgrade did. Reload with JavaScript disabled and the card renders identically, still saying it has not been upgraded.
The greet button proves the second half. It is in the shadow root the parser built, and nothing in the page attached anything to it until connectedCallback ran and adopted that root โ markup from the parser, listener from the class, one component.
Then press "Add a card at runtime" and watch the difference. The same class handles the new element, but there is no declarative template for it, so the guard falls through to attachShadow() and the fallback string โ which contains markup and no <style>. Style is scoped per root, so the runtime card gets none of the first card's appearance, and no rule in the page can supply it either. That is not a flaw in the demo; it is the same boundary from step 04, seen from the angle where forgetting one thing costs you every rule at once.
This page was generated at build time. Its HTML was written to disk before you asked for it, and the server did nothing but hand over a file. That is a good arrangement, and it has one well-known hole: anything on the page made of components is not in that file. The generator emits <my-card> and the browser has to be told separately what one is.
Declarative shadow DOM is what closes the hole without abandoning the arrangement. A generator that can render your components at build time and serialize the result โ the getHTML() round trip from two sections ago โ writes files whose components are already components: styled, composed, and painted on first load, with the JavaScript arriving afterwards to add the behavior. That is not a new idea. It is precisely the argument step 01 made about semantic markup, applied one level up: ship the thing itself, enhance it after.
Be honest about what is not solved. The build has to produce the markup the client would have produced, and "would have produced" is doing a great deal of work in that sentence โ a component that reads the viewport, or the time, or localStorage renders differently in a build environment than in a browser, and the mismatch surfaces as a component that visibly changes shape a moment after load, or as a listener attached to a node that is not the node the user sees. Rendering the components at build time also means running them somewhere with a DOM, which is a constraint on how the components may be written.
And there is no standard, framework-free tooling for any of it yet. The framework-attached implementations are real and shipping; the generic pipeline โ define, render, serialize, guard on this.shadowRoot โ is something you presently assemble yourself from the pieces this step describes. Every piece exists. The convention does not.
Nine steps, and the shape they traced is the one the series index claimed at the start: a component is a name, some markup, some behavior, and a boundary, and the platform ships all four as separate things.
Steps 01 to 03 were the name โ a hyphenated tag as semantics with no script at all, then customElements.define(), upgrade, and the attribute-versus-property surface that makes an element addressable from HTML. Step 04 and step 07 were the boundary: what it costs, and how to design the few deliberate holes anyone else will ever be able to style through. Step 06 and this one were the markup โ inert templates and slots, then markup that arrives already rendered. Step 05 and step 08 were the behavior: the lifecycle, the listener that outlives its element, and what it takes for the browser to treat your element as one of its own form controls.
The through-line is that those four are separable, and that knowing which of them you actually need is most of the skill. Nearly every component in this series could have been built with fewer of the four than it used. A semantic tag and a stylesheet is a legitimate component. A custom element with no shadow root is a legitimate component, and frequently the better one โ it participates in forms, inherits the page's CSS, and is findable by everything that walks the document. Reaching for all four every time is the habit a framework trains, because a framework hands you all four as one object. The platform does not, and that is the advantage, not the friction: you pay for each piece separately, so you can decline the ones you do not need.
For the layer underneath โ how the parser builds the tree these components attach to, what the event loop is doing while a module downloads, and why the flash in this step's first section happens where it does โ see The Browser for Programmers. For the sibling topics, and the other platform capabilities that pair with this one, see the APIs series.