Feature-Detecting Shadow DOM Support: Testing Capabilities, Not Browsers
“Does this browser support Web Components” is the wrong question, and every codebase that asks it ends up with a boolean that is true on engines missing half the API and false on engines that would have worked. There is no single Web Components feature. There is customElements, attachShadow, slotAssignment: 'manual', ElementInternals, CustomStateSet, constructable stylesheets, declarative shadow roots, form association, ::part(), :state(), container queries — each shipped on its own timeline, each detectable independently, and most of them capable of graceful absence.
Detecting them individually is more code than a user-agent check and dramatically more reliable, because it tests the thing the component actually needs. This deep dive belongs to Polyfills & Progressive Enhancement inside Distribution, Testing & Tooling.
Problem Statement: The Detection That Answers a Different Question
// A check that appears in a great many codebases.
const SUPPORTS_WEB_COMPONENTS =
'customElements' in window && 'attachShadow' in Element.prototype;
if (SUPPORTS_WEB_COMPONENTS) {
import('./components/index.js'); // loads EVERY component
} else {
document.body.classList.add('legacy'); // renders a static fallback for everything
}
Two failures, in opposite directions.
The check passes on an engine from 2019 that has attachShadow and lacks ElementInternals, CustomStateSet, declarative shadow roots, and manual slot assignment. Every component loads, and the four that rely on those APIs throw during construction — which in a custom element constructor means the element is marked failed and never upgrades, silently, leaving a blank region with no error visible to the reader.
The check fails on an engine that has everything except attachShadow on Element.prototype because a polyfill installed it on HTMLElement.prototype instead. Every component is disabled and the page renders the legacy fallback for a browser that would have run the real thing.
Root-Cause Analysis
Every specification in this area was written to be detectable, and none of them is detectable by asking about “Web Components”. The reliable technique differs by feature, and there are exactly four shapes.
Property presence works for constructors and methods: 'customElements' in window, 'attachShadow' in Element.prototype, 'assign' in HTMLSlotElement.prototype, 'adoptedStyleSheets' in Document.prototype. It is cheap and accurate for anything that either exists or does not.
Behavioural probing is required when a member exists but does not do the thing. Declarative shadow DOM is the canonical case: shadowrootmode is an attribute the parser either honours or ignores, and no property reflects whether it did. The only reliable test is to parse a fragment and look for the resulting shadow root.
CSS.supports() covers selectors and properties: CSS.supports('selector(:state(x))'), CSS.supports('container-type: inline-size'). The selector() form is itself relatively new, so a component testing selector support should confirm CSS.supports accepts it.
Constructor probing covers APIs that throw rather than being absent. new CustomElementRegistry() throws where scoped registries are unimplemented; new CSSStyleSheet() threw in older Safari. A try/catch around one construction is the answer, evaluated once.
:defined never matches, and no exception reaches the page's error handler in a way that names the component. So a constructor that calls this.attachInternals() on an engine without ElementInternals does not degrade — it produces an invisible, inert element. Detect before the class is defined, and branch on the result inside the constructor, never let the API call be the detection.
The second structural rule: detect once, at module scope. These checks are cheap individually and are not free — a declarative shadow DOM probe parses a document fragment — and running them per instance turns a microsecond into a measurable cost on a page with hundreds of components.
Production-Safe Pattern
// capabilities.js — evaluated once, at module scope, exported frozen.
const has = (object, key) => Boolean(object) && key in object;
/** Declarative shadow DOM has no property to test: parse and look. */
const detectDeclarativeShadowDom = () => {
try {
const probe = document.createElement('div');
// setHTMLUnsafe is itself a later addition, so try both entry points.
const markup = '<x-probe><template shadowrootmode="open"></template></x-probe>';
if (typeof probe.setHTMLUnsafe === 'function') probe.setHTMLUnsafe(markup);
else probe.innerHTML = markup;
return Boolean(probe.firstElementChild?.shadowRoot);
} catch {
return false;
}
};
/** Some APIs throw rather than being absent. */
const constructs = (factory) => {
try {
factory();
return true;
} catch {
return false;
}
};
/** CSS.supports('selector(...)') is itself newish — confirm before trusting it. */
const supportsSelector = (selector) => {
if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return false;
if (!CSS.supports('selector(*)')) return false; // the form itself is unsupported
return CSS.supports(`selector(${selector})`);
};
export const CAPABILITIES = Object.freeze({
customElements: has(globalThis, 'customElements'),
shadowDom: has(Element.prototype, 'attachShadow'),
declarativeShadowDom: detectDeclarativeShadowDom(),
elementInternals: has(HTMLElement.prototype, 'attachInternals'),
customStates: has(globalThis, 'CustomStateSet') && supportsSelector(':state(x)'),
formAssociation: has(globalThis, 'ElementInternals') &&
has(globalThis.ElementInternals?.prototype ?? {}, 'setFormValue'),
constructableStyleSheets: has(Document.prototype, 'adoptedStyleSheets') &&
constructs(() => new CSSStyleSheet()),
manualSlotAssignment: has(HTMLSlotElement.prototype, 'assign'),
scopedRegistries: constructs(() => new CustomElementRegistry()),
containerQueries: typeof CSS !== 'undefined' &&
CSS.supports?.('container-type: inline-size') === true,
parts: supportsSelector('::part(x)')
});
// A component branches on exactly the capabilities it needs, before defining.
import { CAPABILITIES } from './capabilities.js';
class ToneField extends HTMLElement {
static formAssociated = CAPABILITIES.formAssociation;
#internals = null;
constructor() {
super();
// Branch INSIDE the constructor on a value computed OUTSIDE it, so the
// constructor can never throw and leave the element permanently failed.
if (CAPABILITIES.elementInternals) {
this.#internals = this.attachInternals();
}
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `<label><slot></slot><input type="text"></label>`;
if (CAPABILITIES.constructableStyleSheets) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(':host { display: block; } input { inline-size: 100%; }');
root.adoptedStyleSheets = [sheet];
} else {
const style = document.createElement('style');
style.textContent = ':host { display: block; } input { inline-size: 100%; }';
root.prepend(style);
}
}
setInvalid(message) {
if (this.#internals && CAPABILITIES.customStates) {
this.#internals.states.add('invalid');
} else {
// Attribute fallback: consumers style [data-invalid] where :state() is absent.
this.toggleAttribute('data-invalid', true);
}
this.#internals?.setValidity?.({ customError: true }, message);
}
}
// Only define at all if the baseline is present. Everything above is enhancement.
if (CAPABILITIES.customElements && CAPABILITIES.shadowDom) {
customElements.define('tone-field', ToneField);
}
Three properties make this correct rather than merely thorough. The capability object is computed once at module scope and frozen, so the probes run a single time regardless of how many instances exist. Each branch degrades to a working alternative — a <style> element, a data attribute — rather than to nothing. And the constructor never calls an API it has not already confirmed, so it cannot throw and cannot leave elements in the failed state.
The static formAssociated line is worth noticing: it reads a capability at class-definition time, because declaring form association on an engine without ElementInternals makes the definition itself unusable.
Verification
import { CAPABILITIES } from './capabilities.js';
// 1. Every capability is a boolean, never undefined — an undefined check is a bug.
for (const [name, value] of Object.entries(CAPABILITIES)) {
console.assert(typeof value === 'boolean', `${name} must be a boolean`);
}
// 2. Detection is idempotent and cached: the object is frozen.
console.assert(Object.isFrozen(CAPABILITIES), 'capabilities must be computed once');
// 3. The declarative probe agrees with a real parse.
const host = document.createElement('div');
host.setHTMLUnsafe?.('<x-check><template shadowrootmode="open">ok</template></x-check>');
const observed = Boolean(host.firstElementChild?.shadowRoot);
console.assert(observed === CAPABILITIES.declarativeShadowDom, 'probe matches reality');
// 4. The component upgrades and does NOT enter the failed state.
document.body.innerHTML = '<tone-field>Label</tone-field>';
const field = document.querySelector('tone-field');
console.assert(field.matches(':defined'), 'element upgraded without throwing');
console.assert(field.shadowRoot !== null, 'shadow root attached');
// 5. The degraded path produces working behaviour, not silence.
field.setInvalid('Required');
const flagged = field.matches(':state(invalid)') || field.hasAttribute('data-invalid');
console.assert(flagged, 'invalid state expressed through whichever channel exists');
Assertion five is the one that makes the whole approach worth the code: it asserts the outcome rather than the mechanism, so the same test passes on an engine using custom states and on one using the attribute fallback. Tests written against the mechanism have to be skipped per engine, which is how fallback paths rot.
For CI, run the suite in at least two engines. A Chromium-only run reports every capability as true and exercises no fallback branch at all, which means the degraded paths are untested code shipping to real users.
When to Use vs When to Avoid
| Feature | Detection |
|---|---|
| Custom elements | 'customElements' in window |
| Shadow DOM | 'attachShadow' in Element.prototype |
| Declarative shadow DOM | Parse a probe fragment and check for shadowRoot |
ElementInternals |
'attachInternals' in HTMLElement.prototype |
| Custom states | 'CustomStateSet' in window plus a :state() selector check |
| Constructable stylesheets | Property presence plus a new CSSStyleSheet() probe |
| Manual slot assignment | 'assign' in HTMLSlotElement.prototype |
| Scoped registries | try { new CustomElementRegistry() } |
::part() / container queries |
CSS.supports() |
| Anything at all | Never a user-agent string |
The bottom row is not a style preference. User-agent strings are frozen, spoofed, and reduced by design in current browsers, so a check written against one is wrong on arrival and gets wronger. Every feature in the table above has a direct test that costs a few microseconds and stays correct as engines change — which is the whole argument for the technique described in polyfilling custom elements in legacy browsers.
Frequently Asked Questions
Why can't I detect declarative shadow DOM with a property check?
Because it is parser behaviour with no reflected property — shadowrootmode is either honoured or ignored, and nothing on any interface says which. Parsing a small probe fragment and checking for a resulting shadowRoot is the only reliable test.
What happens if a custom element constructor throws?
The element enters the failed state permanently: it never upgrades, :defined never matches, and the region renders empty. Detect capabilities before the class is defined and branch on cached results, so the constructor can never throw.
Should I detect once or per instance?
Once, at module scope, into a frozen object. Some probes parse a fragment, and repeating that per instance is a measurable cost on a page with many components — with no possible change in the answer.
Is a user-agent check ever acceptable?
No. Modern browsers freeze and reduce the string deliberately, and it never described capabilities accurately in the first place. Every feature here has a direct test that stays correct as engines evolve.
Related
- Polyfills & Progressive Enhancement — the parent topic on support strategy.
- Distribution, Testing & Tooling — the section this deep dive belongs to.
- Polyfilling Custom Elements in Legacy Browsers — what to load once a capability is found missing.
- Graceful Degradation Without JavaScript — the floor every fallback path degrades to.
- Deferring Hydration with Islands — using the same capability object to decide what to load.