skip to content

Web Components

You will learn the platform's own component model: custom elements, shadow DOM encapsulation, and templates with slots. Interviewers reach for it to test whether you understand what frameworks add on top of the browser versus what the browser already does.

on this pageshow

explore

questions

27

What rules does customElements.define() enforce on the tag name and the class you pass it, and what happens when you break them?

level: juniorimportance: must knowfreq 70%

answer

  1. hyphen is a contract, not a style rule
  2. reserved namespace for future HTML
  3. two different DOMException types
  4. one class, one name, one time
  5. get() before define() in shared bundles

basics

~10 s

customElements.define(name, class) requires a lowercase name that contains a hyphen and a class that extends HTMLElement. An invalid name throws SyntaxError; registering a name twice, or one class under two names, throws NotSupportedError.

solid answer

~50 s

`customElements.define('my-card', MyCard)` registers a tag name in the document's custom element registry. The name must start with an ASCII lowercase letter, contain no uppercase, and contain at least one hyphen — the hyphen is what guarantees the name can never collide with a future built-in HTML element, and it is also why the parser treats an unknown hyphenated tag as an upgradeable `HTMLElement` rather than `HTMLUnknownElement`. A short list of hyphenated names is reserved (`annotation-xml`, `color-profile`, `font-face`, `missing-glyph` and a few others). The second argument must be a class that ultimately extends `HTMLElement`. Registration is once-only and irreversible: an invalid name throws a `SyntaxError`, defining the same name twice or the same constructor under two names throws a `NotSupportedError`, and there is no way to un-define. `customElements.get('my-card')` tells you whether a name is already taken.

go deeper

for a junior

Be ready to write the two-line define call from memory and state the name rules: lowercase, must contain a hyphen, extends HTMLElement. Say plainly that the hyphen prevents collisions with built-in HTML elements.

for a middle

Explain the mechanics behind the rules: which exception each violation throws, why a hyphenated unknown tag is an HTMLElement awaiting upgrade rather than HTMLUnknownElement, and what customElements.get and whenDefined are for.

for a senior

Show that you have hit the once-only registry in production — duplicate definitions from a twice-loaded bundle or a dev-server reload — and describe the get()-before-define guard plus what it silently trades away when two versions collide.

for a principal

Own the naming policy: who may claim a tag name across teams, whether components ship prefixed or version-suffixed names, and how you keep a shared library from being registered twice on a host page you do not control.

## What define() is for `customElements.define()` is the single entry point that turns a JavaScript class into a real HTML element. After it runs, the browser knows that every `<my-card>` in the document — past, present and future — is an instance of your class, gets your prototype and your methods, and receives your lifecycle callbacks. Before it runs, the same tag is just a generic element the browser is willing to keep around. It takes two required arguments and one optional one: ```js class MyCard extends HTMLElement {} customElements.define('my-card', MyCard); ``` ## The name rules A *valid custom element name* must: - start with an ASCII lowercase letter (`a`–`z`), - contain at least one hyphen (`-`), - contain no ASCII uppercase letters, - not be one of the reserved names: `annotation-xml`, `color-profile`, `font-face`, `font-face-src`, `font-face-uri`, `font-face-format`, `font-face-name`, `missing-glyph`. Those already have meaning in SVG and MathML. So `my-card`, `x-1`, and `acme-user-badge` are legal; `mycard`, `MyCard`, `1-card` and `font-face` are not. A bad name throws a `SyntaxError` `DOMException` at the `define()` call. The hyphen is not a naming convention — it is a forward-compatibility contract. HTML reserves the entire hyphen-free namespace for itself, so a future `<carousel>` element added to the standard can never break your page, and your `<acme-carousel>` can never be redefined out from under you. ## What the parser does with an unknown hyphenated tag This rule also has a concrete parsing consequence. A garbage tag such as `<blah>` becomes an `HTMLUnknownElement`. A hyphenated tag such as `<my-card>` becomes an `HTMLElement` marked internally as an *undefined custom element*: it sits in the DOM, keeps its attributes and children, and is a candidate for upgrade the moment a matching `define()` call happens. CSS can see the difference — `my-card:not(:defined)` matches only while it is still waiting, which is the standard way to hide un-upgraded elements and avoid a flash of unstyled content. ## The class rules The constructor argument must be a class (something constructable, not an arrow function) whose prototype chain ends at `HTMLElement`. Extending `HTMLElement` directly gives you an *autonomous* custom element. Extending a specific interface such as `HTMLButtonElement` gives you a *customized built-in*, and then you must also pass `{ extends: 'button' }` as the third argument so the registry knows which tag it decorates. ## Registration is once-only The registry is append-only for the lifetime of the document: - Defining a name that is already registered throws `NotSupportedError`. - Registering the *same constructor* under two different names also throws `NotSupportedError` — one class, one tag. - There is no `customElements.undefine()` and no way to swap an implementation. That matters in practice more than it sounds. Any bundle that ships a component library and might be loaded twice on one page — an app plus a widget, two micro-frontends, a hot-reloading dev server — will hit the duplicate-definition throw. The common guard is to check first: ```js if (!customElements.get('my-card')) { customElements.define('my-card', MyCard); } ``` This silently keeps whichever copy won the race, which is a tradeoff, not a fix: the two copies may be different versions. ## Related registry methods - `customElements.get(name)` → the constructor, or `undefined` if the name is free. - `customElements.whenDefined(name)` → a promise that resolves with the constructor once the name is registered; useful for code that must wait for a definition it does not own. - `customElements.upgrade(root)` → forces upgrade of already-created elements in a subtree, instead of waiting for them to be inserted into the document. None of these let you replace a definition. Registration is a one-way door, and designing around that — versioned tag names, a single owner per name — is part of shipping custom elements at scale.

  • Before any definition is registered, what does the DOM create for a `<my-card>` element written in the HTML source?
    An ordinary `HTMLElement` instance flagged as an undefined custom element — not `HTMLUnknownElement`, which is what a non-hyphenated unknown tag gets. It keeps its attributes and children, matches `:not(:defined)` in CSS, and is upgraded to your class as soon as a matching `define()` runs.
  • Can you register one class under two tag names, for example an alias?
    No. `customElements.define()` throws `NotSupportedError` if the constructor is already registered under another name. If you genuinely need two tags, define a trivial subclass for the second name, or generate the class from a factory function so each name gets its own constructor.
  • Why do component libraries wrap `define()` in an `if (!customElements.get(name))` guard?
    Because a duplicate definition throws and would break the whole script. If two copies of a library load on one page — an app plus an embedded widget, or a dev server reload — the guard keeps the first registration and skips the second. It is a crash guard, not a version-conflict solution: the surviving copy may be the wrong version.

The registry is like a domain-name registrar for tag names: hyphenated names are the public zone anyone may claim, hyphen-free names are reserved for the standard itself, and once you claim a name it is yours for the life of the page — no transfers, no re-registrations.

saying these in an interview costs you the question

  • Thinks the hyphen is a naming convention you can skip
  • Believes you can redefine or un-define a registered tag name
  • Says an unknown hyphenated tag becomes HTMLUnknownElement
  • Registers one class under several tag names as an alias
  • Thinks define() must run before the element appears in the HTML

context

open as a page

A custom element <user-card> needs to receive an array of user objects from the surrounding page. Why can that array not be passed as an HTML attribute, and what is the standard way to hand it over?

level: juniorimportance: must knowfreq 62%

basics

~20 s

HTML attributes can hold only strings, so an array survives as an attribute only if you serialize it to text and parse it back. Pass rich data as a JavaScript property instead — el.users = [...] — which hands over the real object by reference.

open as a page

In the DOM, what does calling element.attachShadow({ mode: 'open' }) do, and what does the resulting shadow boundary actually encapsulate?

level: juniorimportance: must knowfreq 70%

basics

~20 s

attachShadow creates a ShadowRoot: a separate DOM subtree rendered in place of the element's own children. Its boundary scopes selectors both ways, so outer CSS and document.querySelector cannot reach inside, and styles defined inside cannot leak out.

open as a page

A custom element attaches a shadow root and renders <span class="label">Hi</span> inside it. Why does the page's `body { font-family: Georgia }` change that text, while the page's `body .label { color: red }` does nothing?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Inheritance crosses a shadow boundary; selector matching does not. font-family is an inherited property, so its computed value flows from the host down into the shadow tree. A page selector can never match a node inside another tree's shadow root.

open as a page

In HTML, what does the <template> element do with the markup written inside it, and how do you get a copy of that markup onto the page?

level: juniorimportance: must knowfreq 58%

basics

~20 s

The <template> element parses its markup into an inert DocumentFragment exposed as template.content: nothing renders, scripts do not run, images are not fetched. To use it, clone that fragment with cloneNode(true) and insert the copy.

open as a page

Which lifecycle callbacks does the browser call on a custom element registered with customElements.define(), and what triggers each one?

level: middleimportance: must knowfreq 80%

basics

~20 s

Four: connectedCallback when the element becomes connected to a document, disconnectedCallback when it is removed, adoptedCallback(oldDoc, newDoc) when it moves to another document, and attributeChangedCallback(name, oldValue, newValue) for attributes listed in the static observedAttributes array.

open as a page

A reusable <color-picker> custom element dispatches new CustomEvent('picked', { detail }) from a button inside its shadow root, and a listener attached to the element in the outer page never fires. What is wrong, and how should a component's outgoing events be configured?

level: middleimportance: must knowfreq 55%

basics

~20 s

CustomEvent defaults to bubbles: false and composed: false, so an event fired inside a shadow root stops at that boundary. A component's public events must be created with bubbles: true and composed: true to reach listeners in the outer page.

open as a page

A click on a <button> inside a component's open shadow root is handled by a listener on the shadow host, but event.target is the host element rather than the button. Why does the browser report it that way, and how do you find the element that was really clicked?

level: middleimportance: must knowfreq 54%

basics

~20 s

The browser retargets an event's target as it crosses a shadow boundary, rewriting it to the nearest ancestor in the same tree as the listening node, so encapsulation holds. Use event.composedPath()[0] to reach the real innermost element.

open as a page

What is the difference between attaching a shadow root with attachShadow({ mode: 'open' }) and attachShadow({ mode: 'closed' }), and is closed mode a security boundary?

level: middleimportance: must knowfreq 58%

basics

~20 s

Open mode exposes the root as element.shadowRoot; closed mode makes that property return null, so only the code holding the returned root can reach inside. Closed is a discouragement, not a security boundary — same-page script can still get in.

open as a page

A component's internals live in a shadow root that page CSS cannot select. How do CSS custom properties let the page theme it anyway, and what are the limits of that contract?

level: middleimportance: must knowfreq 65%

basics

~20 s

Custom properties are inherited properties, so a value set on the host or any ancestor flows into the shadow tree. The component author must opt in by consuming it in a var() call — only the hooks they wired are themeable, and the names become public API.

open as a page

In a shadow-DOM component, what does putting `part="label"` on an internal element let the page's CSS do, and what can the page still not do through `::part()`?

level: middleimportance: must knowfreq 48%

basics

~20 s

A part name exposes that one element to outside CSS: the page writes my-el::part(label) { ... } and may set any property on it. The page still cannot select the part's descendants, cannot reach unexposed elements, and cannot see parts of nested shadow roots unless they are re-exported.

open as a page

In a Web Component's shadow DOM, how does a <slot> decide which of the host element's children it renders, and what happens to a child whose slot attribute matches no slot?

level: middleimportance: must knowfreq 52%

basics

~20 s

A <slot> without a name takes the host's direct children that have no slot attribute; <slot name="x"> takes the direct children whose slot="x" matches exactly. A child matching no slot is never rendered, silently. An empty slot renders its own children as fallback.

open as a page

In the custom elements API, what is the difference between an autonomous custom element and a customized built-in, and how do you declare and use each?

level: middleimportance: should knowfreq 35%

basics

~20 s

An autonomous custom element extends HTMLElement and is used as its own tag, such as <my-button>. A customized built-in extends a specific interface like HTMLButtonElement, is registered with { extends: 'button' }, and is used as <button is="my-button">, inheriting that element's behaviour.

open as a page

What is a custom element's constructor allowed to do, and why does the specification forbid setting attributes or adding light-DOM children there?

level: middleimportance: should knowfreq 50%

basics

~20 s

The constructor may call super(), set instance fields and attach a shadow root, but must not touch attributes, add children, or inspect the document. Doing so breaks document.createElement, which throws NotSupportedError if the constructed element already has attributes or children.

open as a page

What does the markup <template shadowrootmode="open"> do when the HTML parser encounters it, and which server-rendering problem does it solve for components that use shadow DOM?

level: middleimportance: should knowfreq 42%

basics

~20 s

The HTML parser turns that template into a real shadow root on its parent element instead of leaving a template in the DOM. It lets a server ship shadow DOM and its scoped styles in the HTML itself, so a component renders correctly before any JavaScript runs.

open as a page

Inside a shadow root's own stylesheet, what do the :host and :host-context() selectors match, and why can't the component just write its own tag name as the selector instead?

level: middleimportance: should knowfreq 40%

basics

~20 s

:host matches the shadow host from inside its shadow tree, and :host(sel) matches only when the host also matches sel. :host-context(sel) matches when the host or any ancestor matches sel. A plain tag selector fails because the host lives outside the tree.

open as a page

In a component's shadow-root CSS, what can the `::slotted()` pseudo-element actually select, and why do so many attempts to style slotted content with it fail?

level: middleimportance: should knowfreq 38%

basics

~20 s

::slotted() matches only the top-level nodes assigned to a slot, and takes a single compound selector — no combinators, no descendants. It also loses to the page's own rules on those elements, because slotted nodes still belong to the document tree.

open as a page

A custom element <my-card> renders <slot></slot> in its shadow root, and the page writes <my-card><span>Hi</span></my-card>. After slotting, where does that <span> live in the DOM, and which of document.querySelector, host.querySelector and shadowRoot.querySelector find it?

level: middleimportance: should knowfreq 40%

basics

~20 s

The <span> never moves. It stays a child of the host in the ordinary document tree, so document.querySelector and host.querySelector find it, while shadowRoot.querySelector does not. Slotting only projects it into the flattened tree the browser renders.

open as a page

A custom element's connectedCallback reads this.querySelector('.label') and gets null when the defining script runs inline in <head>, but finds the element when the same script is loaded as a module at the end of <body>. Why does the timing change the result?

level: seniorimportance: should knowfreq 40%

basics

~20 s

With the definition already registered, the HTML parser upgrades the element the moment it sees the start tag, so connectedCallback runs before the children are parsed. When the definition arrives after parsing, the upgrade finds a complete element, so the children are already there.

open as a page

A custom element registers a window resize listener and starts a fetch in connectedCallback. What goes wrong as the element is moved around the DOM, and how do you make the lifecycle safe?

level: seniorimportance: should knowfreq 45%

basics

~20 s

connectedCallback runs again on every reinsertion, so a move stacks up duplicate listeners and duplicate fetches. Everything it registers must be released in disconnectedCallback, and setup must be idempotent — and disconnectedCallback never runs on page unload.

open as a page

A custom element <money-input> wraps an <input> inside its shadow root. When the surrounding <form> is submitted, FormData contains no entry for it. Why is the value missing, and how do you make a custom element behave as a real form control?

level: seniorimportance: should knowfreq 32%

basics

~20 s

An input inside a shadow root is not associated with a form in the outer document, so nothing it holds is submitted. Declare static formAssociated = true on the element, call this.attachInternals(), and report the value with internals.setFormValue().

open as a page

A page server-renders custom elements with declarative shadow DOM. When the component script finally loads, each element's constructor calls this.attachShadow({ mode: 'open' }) and rewrites its markup, and users see a flash. How do you make the client-side definition adopt the server-rendered shadow root, and what else races here?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Check for an existing root first — const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }) — and skip re-rendering when the server markup is already there, only wiring listeners. The other race is properties assigned before the element upgraded, which shadow the class accessors.

open as a page

A custom element clones a <style> block into every instance's shadow root, and one page renders several hundred instances. What does that cost, and what changes if the component assigns to shadowRoot.adoptedStyleSheets instead?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Every shadow root gets its own style element and its own CSSOM stylesheet object, so rules are duplicated per instance in memory and on every DOM insertion. adoptedStyleSheets lets many roots share one constructed CSSStyleSheet, parsed once and updated in one place.

open as a page

A page renders 500 instances of a component whose shadow root each contains the same 8 KB `<style>` block. What changes if all of them adopt one constructed `CSSStyleSheet` instead, and how does a theme switch then propagate?

level: seniorimportance: should knowfreq 32%

basics

~20 s

One new CSSStyleSheet() parsed once and assigned to every root's adoptedStyleSheets replaces 500 parses and 500 rule sets with a single shared CSSOM object. Mutating that one sheet with replaceSync updates every adopting root at once.

open as a page

In a Web Component, a shadow-root listener recomputes on a slot's slotchange event, but it never fires when the caller edits the text inside an element that is already slotted. Why not, and what does slotchange actually signal?

level: seniorimportance: should knowfreq 32%

basics

~20 s

slotchange signals that a slot's assigned node list changed — a slottable child added, removed, reordered, or re-routed by a slot attribute. It says nothing about mutations inside an already-assigned node, so editing that element's text or attributes fires nothing.

open as a page

You own a shared web-component library. How do you decide what each component exposes as CSS custom properties versus `::part()` names, and what does each choice cost you later?

level: principalimportance: should knowfreq 28%

basics

~20 s

Custom properties expose values and keep internals refactorable; parts expose whole elements and let consumers apply anything, which freezes that element's structure and box behaviour as de-facto API. Default to tokens; grant parts only where needs are genuinely unbounded.

open as a page

You maintain a design-system library of custom elements. How do you decide what a component exposes as a named <slot> versus what it renders itself, and what does publishing a slot name commit you to?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Slot what the caller must own — arbitrary content whose markup you cannot predict; render yourself what the component is responsible for, such as structure, layout and accessibility wiring. A published slot name is public API: renaming one makes consumer content vanish silently.

open as a page