An element of your own
Inventing a tag and teaching the browser what it renders: a class, a shadow root, slots, and one line to register it.
A tag you invent
HTML has a fixed vocabulary, and one hole in it: any tag with a hyphen in its name belongs to you. <user-card> is yours; <usercard> is reserved. That single rule is what lets the platform grow without collisions.
Registering the tag takes three things: a class that extends HTMLElement, a name with a hyphen, and one call to customElements.define("user-card", UserCard). From that moment every <user-card> in the document comes to life.
The hyphen is not a style rule. It is a namespace reservation: the HTML parser treats an unknown hyphenated tag as a custom element and lets the registry upgrade it, while a tag without a hyphen is assumed to be a misspelling of something standard. That is why customElements.define("usercard", ...) throws.
The definition
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" }).innerHTML = `
<style>h2 { font-size: 1rem; margin: 0 0 4px; }</style>
<h2><slot name="name">Unknown</slot></h2>
<p><slot name="role"></slot></p>
`;
}
}
customElements.define("user-card", UserCard);The shadow root
attachShadow({ mode: "open" }) gives the element a private tree of markup and styles. Styles written inside it do not leak out to the page, and the page’s styles do not reach in — which is how a card stays a card wherever it is used.
The constructor runs once, when the element appears in the document. That is where the shadow root is attached and its markup set, ready before the first paint.
A shadow root is not required. An element can render into its ordinary children, and some do. The shadow root earns its place when the component needs styles that must not leak, or markup that the page should not accidentally query and change.
Slots let the page keep its content
A <slot> is a hole the element’s own children pass through. The page writes <user-card><span slot="name">Ada</span></user-card> and the shadow markup decides where that span appears. The words stay in the document — which is why the page still reads sensibly before any script runs.
That is progressive enhancement in one element: the content is HTML, the presentation is the component, and a failed script costs the styling rather than the meaning.
Lifecycle: the browser calls you
A custom element has named moments. The constructor runs once, when the element is created or upgraded. connectedCallback runs each time it is inserted into the document, and disconnectedCallback runs when it leaves. attributeChangedCallback runs when an observed attribute changes — including once for each attribute already present at upgrade.
The split matters because the moments are not interchangeable. The constructor can run before the element’s children have been parsed, so reading them there is unreliable; work that needs the page belongs in connectedCallback. Anything that should react to an attribute needs that attribute named in observedAttributes first.
Attributes are text, and the shadow wall has doors
An HTML attribute is always a string: <status-badge level="warn"> hands over "warn", not a number or an object. The element decides what that string means, and attributeChangedCallback is where the translation happens. Setting the same value again does not fire it, because nothing changed.
The shadow root keeps page styles out, but it is not a sealed box. :host styles the element from inside, ::slotted(...) styles the content passed through a slot, and CSS custom properties cross the boundary in both directions. That is enough to let a page theme a component without reaching into its private tree.
A worked upgrade, in order
Suppose the page contains two <user-card> elements before the script runs. The browser treats them as unknown inline elements and renders their text plainly. When customElements.define runs, each existing element is upgraded: a new instance of the class is created, the constructor attaches a shadow root and fills it, and the element is re-rendered with its slots filled.
Any <user-card> added later is constructed as soon as it is inserted. That is why the definition has to run before the elements are used if you want to avoid a flash of unstyled content, and why the script is often placed in the head with defer rather than at the end of the body.
Three mistakes: using this before super() in the constructor throws; reading children or attributes in the constructor is unreliable because they may not be parsed yet — use connectedCallback; and a name without a hyphen, or one registered twice, fails at customElements.define.
Try these next
- Add a
connectedCallbackand adisconnectedCallbackthat log, then move the element in the page. - Observe a second attribute and change it from the console to watch the render update.
- Style the element from inside with
:hostand set a custom property from the page.
Teach the tag to render
The page uses user-card twice and the browser renders it as a plain block. Define the element: a shadow root with a heading and a line, and slots so each card’s name and role flow in.
3 left to do
- The element is registered
- It renders through a shadow root
- The content flows through slots
class UserCard extends HTMLElement with a constructor that attaches the shadow root and sets innerHTML — then customElements.define("user-card", UserCard).
A badge that reads its attribute
Define `status-badge` so it renders the word OK when `level="ok"` and Warning when `level="warn"`. Watch the attribute and update the badge when it changes.
3 left to do
- The level attribute is observed
- Changes are handled in attributeChangedCallback
- The element is registered
Give the class a static observedAttributes returning ["level"]; render a span into a shadow root; in attributeChangedCallback, choose the word from the new value.
Keep the words in the page, not in the script. An element that writes its own content is invisible to search engines, unreadable when the script fails, and harder to translate.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Which tag name is yours to define?
